@jsenv/navi 0.29.141 → 0.29.143

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.
@@ -5,7 +5,7 @@
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
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, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, initFocusGroup, scrollIntoViewScoped, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, isTouchDrivenEvent, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
- export { clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
8
+ export { chainEvent, clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { isValidElement, h, Fragment, createContext, render, toChildArray, options, cloneElement } from "preact";
11
11
  import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, useState, useEffect, useMemo, useId } from "preact/hooks";
@@ -16349,6 +16349,14 @@ const generateSignalId = () => {
16349
16349
  * value, and it goes back to the default value when the route stops matching. Use it for params like an
16350
16350
  * "edit this one" id, where every other link to the screen must lead to the plain screen.
16351
16351
  * Incompatible with `persists` (throws).
16352
+ * @param {"replace"|"push"} [options.history="replace"] - what writing this state
16353
+ * is worth in the browser's history, when it is bound to a url (a route's
16354
+ * `searchParams`, a path param). Replacement by default: a state that
16355
+ * qualifies the screen one is on — a step in a form, a view mode, a sort — is
16356
+ * not a place, and one entry per write turns a single back-press into as many
16357
+ * as the user moved. `"push"` is for a state whose values ARE places one came
16358
+ * from (the photo one is looking at in a gallery). Whatever is said here, one
16359
+ * write can say otherwise: `signal.set(value, { history })`.
16352
16360
  * @param {boolean} [options.debug=false] - Enable debug logging for this signal's operations
16353
16361
  * @returns {import("@preact/signals").Signal} A signal that can be synchronized with a source signal and/or persisted in localStorage. The signal includes a `validity` property for validation state.
16354
16362
  *
@@ -16422,8 +16430,14 @@ const stateSignal = (defaultValue, options = {}) => {
16422
16430
  ignoreArrayOrder,
16423
16431
  autoFix,
16424
16432
  weak = false,
16433
+ history = "replace",
16425
16434
  } = options;
16426
16435
 
16436
+ if (history !== "replace" && history !== "push") {
16437
+ throw new TypeError(
16438
+ `stateSignal "${id}": history must be "replace" or "push", got ${history}.`,
16439
+ );
16440
+ }
16427
16441
  if (weak && persists) {
16428
16442
  throw new TypeError(
16429
16443
  `stateSignal "${id}": weak and persists are contradictory — a weak param qualifies one visit, it cannot be restored from a previous session.`,
@@ -16600,7 +16614,35 @@ const stateSignal = (defaultValue, options = {}) => {
16600
16614
  configurable: true,
16601
16615
  });
16602
16616
 
16617
+ // What the write being made right now asks of the history, over what this
16618
+ // state declares. It lives for the length of one assignment: the effect that
16619
+ // writes the url runs synchronously inside it (see route.js), and reads it
16620
+ // there. A write made inside batch() is therefore out of its reach — say the
16621
+ // history on the state itself for those.
16622
+ let historyForCurrentWrite;
16603
16623
  const facadeSignal = preactSignal;
16624
+ /**
16625
+ * Write the state, saying what this ONE write is worth in the history.
16626
+ *
16627
+ * The state says which of the two it usually is (`history` above); a writer
16628
+ * that knows THIS move is not one of them says so here — a slide reached by
16629
+ * dragging inside a container whose slides are places one came from.
16630
+ *
16631
+ * @param {any} value
16632
+ * @param {{history?: "replace"|"push"}} [options]
16633
+ */
16634
+ facadeSignal.set = (value, { history: historyAsked } = {}) => {
16635
+ if (historyAsked === undefined) {
16636
+ facadeSignal.value = value;
16637
+ return;
16638
+ }
16639
+ historyForCurrentWrite = historyAsked;
16640
+ try {
16641
+ facadeSignal.value = value;
16642
+ } finally {
16643
+ historyForCurrentWrite = undefined;
16644
+ }
16645
+ };
16604
16646
  facadeSignal.validity = validity;
16605
16647
  facadeSignal.validSignal = computed(() => {
16606
16648
  // Reading facadeSignal.value establishes the reactive dependency.
@@ -16745,6 +16787,8 @@ const stateSignal = (defaultValue, options = {}) => {
16745
16787
  localStorageKey,
16746
16788
  debug,
16747
16789
  ...options,
16790
+ history,
16791
+ getHistory: () => historyForCurrentWrite || history,
16748
16792
  };
16749
16793
  globalSignalRegistry.set(signalIdString, {
16750
16794
  signal: facadeSignal,
@@ -18012,9 +18056,20 @@ const POSITION_PROPS = {
18012
18056
  return { transform: `skew(${value})` };
18013
18057
  },
18014
18058
  };
18059
+ // Only the inline axis has something to truncate here, so the clip must not
18060
+ // constrain the block one: `hidden` would make the element a scroll container,
18061
+ // whose flex automatic minimum size is 0, and a single line inside a column
18062
+ // flex container shorter than 1lh then shrinks under its own line box and gets
18063
+ // cut through the glyphs. `clip` keeps the content-based minimum, so the line
18064
+ // overflows visibly instead. The margin leaves room for ink drawn outside the
18065
+ // advance width; it starts from the padding box, having no hidden line to hold
18066
+ // back the way lineClampStyles below does.
18015
18067
  const singleLineEllipsisStyles = () => {
18016
18068
  return {
18017
- overflow: "hidden",
18069
+ overflow: "clip",
18070
+ // The `padding-box` keyword is redundant (it is the default box) but
18071
+ // Chromium drops the declaration when a bare calc() follows the property.
18072
+ overflowClipMargin: "padding-box calc((1lh - 1em) / 2)",
18018
18073
  textOverflow: "ellipsis",
18019
18074
  overflowWrap: "normal",
18020
18075
  };
@@ -24540,7 +24595,15 @@ let isUpdatingRoutesFromUrl = false;
24540
24595
  * `:name=${signal}` binds that param to a signal.
24541
24596
  * @param {object} [options]
24542
24597
  * @param {Object<string, import("@preact/signals").Signal>} [options.searchParams]
24543
- * Search params this route two-way syncs with, by name.
24598
+ * Search params this route two-way syncs with, by name. The signal and the url
24599
+ * are the same state: writing the signal rewrites the address, an address
24600
+ * arriving from outside writes the signal, and the param disappears from the
24601
+ * url while the signal holds its default.
24602
+ * Writing one AMENDS the history entry one is on — a param qualifies the
24603
+ * screen, it is not a place, and one entry per write turns a single
24604
+ * back-press into as many as the user moved. A state whose values ARE places
24605
+ * says so where it is declared (`stateSignal(v, { history: "push" })`), and
24606
+ * one write can still say otherwise (`signal.set(v, { history })`).
24544
24607
  * @param {Object<string, RegExp | Array | ((value: string) => boolean)>} [options.params]
24545
24608
  * Which values a path param accepts, by param name: a regexp tested against the
24546
24609
  * decoded segment, the list of accepted values (compared as strings, so it can
@@ -24711,17 +24774,20 @@ const route = (
24711
24774
  const routeUrl = route.buildUrl(params);
24712
24775
  return integration.navTo(routeUrl, options);
24713
24776
  };
24714
- route.redirectTo = (params, { callReason } = {}) => {
24777
+ route.redirectTo = (params, { callReason, history = "replace" } = {}) => {
24715
24778
  if (!integration) {
24716
24779
  return Promise.resolve();
24717
24780
  }
24718
24781
  const routeUrl = route.buildUrl(params);
24719
24782
  return integration.navTo(routeUrl, {
24720
- replace: true,
24783
+ replace: history !== "push",
24721
24784
  callReason,
24722
24785
  });
24723
24786
  };
24724
- route.replaceParams = (newParams, { callReason, isSignalChange } = {}) => {
24787
+ route.replaceParams = (
24788
+ newParams,
24789
+ { callReason, isSignalChange, history = "replace" } = {},
24790
+ ) => {
24725
24791
  const matching = route.matchingSignal.peek();
24726
24792
  if (!matching) {
24727
24793
  console.warn(
@@ -24774,6 +24840,7 @@ const route = (
24774
24840
  }
24775
24841
  return mostSpecificRoute.redirectTo(newParams, {
24776
24842
  callReason: `replaceParams delegation from ${route} to ${mostSpecificRoute} (original reason: ${callReason})`,
24843
+ history,
24777
24844
  });
24778
24845
  }
24779
24846
 
@@ -24789,7 +24856,7 @@ const route = (
24789
24856
  newParams,
24790
24857
  );
24791
24858
  return integration.navTo(targetUrl, {
24792
- replace: true,
24859
+ replace: history !== "push",
24793
24860
  callReason,
24794
24861
  });
24795
24862
  };
@@ -24860,6 +24927,13 @@ const route = (
24860
24927
  const { connections } = routePattern;
24861
24928
  for (const connection of connections) {
24862
24929
  const { signal: paramSignal, debug, paramName } = connection;
24930
+ // What a write of this state is worth in the history: what the state
24931
+ // declares, and what the write happening right now says instead (see
24932
+ // stateSignal's `history` and its `set`). Read at the moment of the
24933
+ // navigation and never before. A plain signal says nothing and replaces,
24934
+ // which is what a param qualifying a screen is.
24935
+ const historyOfWrite = () =>
24936
+ paramSignal.options?.getHistory?.() || "replace";
24863
24937
  if (debug) {
24864
24938
  console.debug(
24865
24939
  `[route] connecting url param "${paramName}" to signal`,
@@ -24902,6 +24976,7 @@ const route = (
24902
24976
  {
24903
24977
  callReason: `${paramName} signal change on ${route}`,
24904
24978
  isSignalChange: true,
24979
+ history: historyOfWrite(),
24905
24980
  },
24906
24981
  );
24907
24982
  return;
@@ -24919,6 +24994,7 @@ const route = (
24919
24994
  {
24920
24995
  callReason: `${paramName} signal reset to default on ${route}`,
24921
24996
  isSignalChange: true,
24997
+ history: historyOfWrite(),
24922
24998
  },
24923
24999
  );
24924
25000
  return;
@@ -24938,6 +25014,7 @@ const route = (
24938
25014
  {
24939
25015
  callReason: `${paramName} signal change on ${route}`,
24940
25016
  isSignalChange: true,
25017
+ history: historyOfWrite(),
24941
25018
  },
24942
25019
  );
24943
25020
  });
@@ -30465,7 +30542,16 @@ const anyMatchingRouteSignal = (routes) => {
30465
30542
  * @param {Element} element The element asking — the command's source, and the
30466
30543
  * anchor a popup opens on unless `anchor` says otherwise.
30467
30544
  * @param {string} command
30468
- * @param {Event} event What the user did.
30545
+ * @param {Event} event What caused this. Mandatory: it is what makes a command
30546
+ * traceable back to its origin — the debug panel groups everything a gesture
30547
+ * set off under it, and the gates below read it to know whether the default
30548
+ * was already prevented and which mouse button was pressed. When nothing was
30549
+ * handed over — a timer firing, an action settling, a signal changing — build
30550
+ * a `CustomEvent` that names what happened and chain it to whatever preceded
30551
+ * it, rather than leaving the origin unsaid:
30552
+ * const expiredEvent = new CustomEvent("session_expired");
30553
+ * chainEvent(expiredEvent, causeEvent); // when something did precede it
30554
+ * triggerNaviCommand(dialogEl, "--navi-open", expiredEvent);
30469
30555
  * @param {object} [options]
30470
30556
  * @param {boolean} [options.optional] No suitable target is not a warning.
30471
30557
  * @param {any} [options.value] What the command is about, carried to whoever
@@ -30482,6 +30568,11 @@ const triggerNaviCommand = (
30482
30568
  event,
30483
30569
  { optional, value, anchor } = {},
30484
30570
  ) => {
30571
+ if (!event) {
30572
+ throw new Error(
30573
+ `"${command}" triggered without an event: it is mandatory, a command must say what caused it. Pass the gesture, or a CustomEvent naming the cause when no gesture did — see triggerNaviCommand's jsdoc.`,
30574
+ );
30575
+ }
30485
30576
  const naviCommand =
30486
30577
  NAVI_COMMANDS[command] || NAVI_COMMANDS[commandName(command)];
30487
30578
  if (!naviCommand) {
@@ -33597,6 +33688,12 @@ const GROUP_DEFAULTS = {
33597
33688
  *
33598
33689
  * **Filtering**: `childControlFilter` can exclude certain child types from aggregation
33599
33690
  * (e.g. ignoring buttons inside a selectable list).
33691
+ *
33692
+ * **Aggregating**: `aggregateChildStates(children, fallbackState, stateNow)` — what the
33693
+ * children add up to. `fallbackState` is the empty of the declared `stateType`;
33694
+ * `stateNow` is what the group is worth as it is asked, which an aggregate reads to
33695
+ * answer for children that are not there (a selectable list whose selected row is not
33696
+ * drawn).
33600
33697
  */
33601
33698
  const useUIGroupStateController = (
33602
33699
  props,
@@ -33753,16 +33850,22 @@ const useUIGroupStateController = (
33753
33850
  // taking that for an answer is how a value handed to it evaporates on
33754
33851
  // the way in, and how that emptiness then travels back up to whoever
33755
33852
  // handed it (a picker showing its row as unanswered).
33756
- const aggregateGroupUIState = (whenNobodyCanAnswer) => {
33853
+ //
33854
+ // `stateNow` is what the group is worth as it is asked: what it keeps
33855
+ // when there is nobody to ask, and what an aggregate reads to answer for
33856
+ // what its children do not say — a selection whose row is not drawn (see
33857
+ // ListSelectable) lives there and nowhere else.
33858
+ const aggregateGroupUIState = (stateNow) => {
33757
33859
  const someChildCanAnswer = childUIStateControllerArray.some(
33758
33860
  shouldPropagateStateToChild,
33759
33861
  );
33760
33862
  if (!someChildCanAnswer) {
33761
- return whenNobodyCanAnswer;
33863
+ return stateNow;
33762
33864
  }
33763
33865
  const aggChildState = resolvedAggregateChildStates(
33764
33866
  childUIStateControllerArray,
33765
33867
  fallbackState,
33868
+ stateNow,
33766
33869
  );
33767
33870
  if (aggChildState !== undefined) {
33768
33871
  return aggChildState;
@@ -34286,8 +34389,6 @@ const useUIGroupStateController = (
34286
34389
  // ── update: runs every render after the first ─────────────────────────
34287
34390
  (s) => {
34288
34391
  const { controller } = s;
34289
- const prevValue = controller.value;
34290
- const prevHasValueProp = controller.hasValueProp;
34291
34392
  const prevDefaultValue = controller.defaultValue;
34292
34393
  controller.props = props;
34293
34394
  controller.ref = ref;
@@ -34305,10 +34406,14 @@ const useUIGroupStateController = (
34305
34406
  controller.placeChildrenUIState(groupUIState, propagateDownEvent);
34306
34407
  controller.syncInternalState(groupUIState);
34307
34408
  };
34308
- if (
34309
- hasValueProp &&
34310
- (!prevHasValueProp || !compareTwoJsValues(value, prevValue))
34311
- ) {
34409
+ // A controlled group goes on showing the value it is given. A child
34410
+ // answering for itself moves what the group is worth — that is what
34411
+ // `uiAction` reports — but the value stays the owner's, so the test is
34412
+ // against what the children are showing, not against the value handed
34413
+ // down last time. A popup reopened on another subject hands down the very
34414
+ // same empty value it did before, and the selection left inside it from
34415
+ // the previous opening is what has to go.
34416
+ if (hasValueProp && !compareTwoJsValues(value, controller.uiState)) {
34312
34417
  placeChildrenFrom(value);
34313
34418
  }
34314
34419
  if (
@@ -38987,8 +39092,17 @@ const css$X = /* css */`
38987
39092
  display: block;
38988
39093
  min-width: 0;
38989
39094
  text-overflow: ellipsis;
38990
- overflow: hidden;
39095
+ /* Only the inline axis has something to truncate, so the clip must not
39096
+ constrain the block one: overflow hidden would make the element a
39097
+ scroll container, whose flex automatic minimum size is 0, and inside a
39098
+ column flex container shorter than 1lh the text then shrinks under its
39099
+ own line box and gets cut through the glyphs. The margin leaves room
39100
+ for ink drawn outside the advance width. */
39101
+ overflow: clip;
38991
39102
  overflow-wrap: normal;
39103
+ /* The padding-box keyword is redundant (it is the default box) but
39104
+ Chromium drops the declaration when a bare calc() follows. */
39105
+ overflow-clip-margin: padding-box calc((1lh - 1em) / 2);
38992
39106
  }
38993
39107
 
38994
39108
  &[data-skeleton] {
@@ -51102,6 +51216,10 @@ const causeOfEvent = event => {
51102
51216
  }
51103
51217
  return "command";
51104
51218
  };
51219
+
51220
+ // The causes that are not places one went, whatever the state they are written
51221
+ // into says (see writeAreaAsked).
51222
+ const CAUSES_WRITTEN_BY_REPLACEMENT = ["drag", "refusal", "state"];
51105
51223
  const readArea = slideElement => slideElement.getAttribute("data-slide-area") || slideElement.id || "";
51106
51224
 
51107
51225
  /**
@@ -51138,33 +51256,65 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
51138
51256
  * repeated spans), except that a row needs no trailing hole: what is not
51139
51257
  * written is simply not there.
51140
51258
  * @param {string} [props.current] - area (or id) of the slide being shown; omit
51141
- * to keep it here and drive it by command.
51142
- * @param {import("@preact/signals").Signal<string>} [props.signal] - the same
51143
- * thing said the way every navi control says it: the container shows the area
51144
- * the signal holds, and writes into it the area it travels to. One binding
51145
- * instead of `current` + `onCurrentChange`, and the state stays where the app
51146
- * put it — which is what lets something else read where the slides are (a
51147
- * field carrying the current tab into a form, see
51259
+ * to keep it here and drive it by command. An area named from outside is a
51260
+ * REQUEST rather than a placement — see `signal` for what that means.
51261
+ * @param {import("@preact/signals").Signal<string>} [props.signal] - where the
51262
+ * area lives, said the way every navi control says it: the container shows
51263
+ * the area the signal holds, and writes into it the area it travels to. One
51264
+ * binding instead of `current` + `onCurrentChange`, and the state stays where
51265
+ * the app put it — which is what lets something else read where the slides
51266
+ * are (a field carrying the current tab into a form, see
51148
51267
  * docs/control_object.md#a-settings-sheet) or move them by writing it.
51149
51268
  * Excludes `current`; `onCurrentChange` still fires, for the `cause` and for
51150
51269
  * the right to refuse.
51151
- * @param {string} [props.defaultCurrent] - which slide to open on, when the
51152
- * travel is left to the container. Mount-only, like every other `default*`:
51270
+ * An area written from outside is WALKED to, not jumped to: every slide
51271
+ * between here and there is asked to let go the way a key going that way
51272
+ * would ask it, and the first one that holds (`preventNav`, a `required` step
51273
+ * still unanswered) is where one stops — after which the signal is written
51274
+ * with the area actually shown, so it says where one IS and never where one
51275
+ * asked to be.
51276
+ * PUTTING THE AREA IN THE URL is that binding and nothing more: hand it a
51277
+ * `stateSignal` a route declares as a search param, and `?step=<area>` is
51278
+ * written on every travel, read on a load, a bookmark, a link, a traversal —
51279
+ * through the walk above, so an address cannot open a slide the walk may not
51280
+ * reach.
51281
+ * ```js
51282
+ * const stepSignal = stateSignal("when", { id: "step", weak: true });
51283
+ * route("/alerts/:id/edit", { searchParams: { step: stepSignal } });
51284
+ * ```
51285
+ * ```jsx
51286
+ * <SlideContainer signal={stepSignal}>
51287
+ * ```
51288
+ * Where it opens is the state's own default (`stateSignal`'s first argument —
51289
+ * a signal there, for a start that depends on where one is), and the param
51290
+ * stays out of the address while the area IS that default. `weak` keeps the
51291
+ * step from being inherited by links built to that route. Written by
51292
+ * replacement unless the state says `history: "push"`, and even then a slide
51293
+ * reached by DRAGGING replaces — see docs/navigation.md.
51294
+ * @param {string} [props.defaultCurrent] - which slide to open on, for a
51295
+ * container that owns its position. Mount-only, like every other `default*`:
51153
51296
  * it says where one starts, not where one is — say `current` for that.
51154
51297
  * Without it the first slide is the one shown, the way a stack of pages opens
51155
51298
  * on its first page.
51156
- * @param {(area: string, detail: {cause: "drag"|"keyboard"|"command"|"code"|"url", event: Event}) => void|false|Promise<void|false>} [props.onCurrentChange]
51299
+ * A container bound to a `signal` does not own its position, and where it
51300
+ * opens is that state's own default (`stateSignal`'s first argument) rather
51301
+ * than this: one place says where the area starts, and it is the place a
51302
+ * reset goes back to. This still answers for a signal holding nothing at
51303
+ * all.
51304
+ * @param {(area: string, detail: {cause: "drag"|"keyboard"|"command"|"code"|"state", event: Event}) => void|false|Promise<void|false>} [props.onCurrentChange]
51157
51305
  * - the slide being shown has changed. `cause` says what asked for it, which
51158
- * is what tells a place browsed past from a place aimed at: a caller writing
51159
- * this into the URL pushes a history entry for a tab that was pressed and
51160
- * replaces the current one for a slide that was dragged, so three swipes back
51161
- * and forth do not bury the way out of the page.
51306
+ * is what tells a place browsed past from a place aimed at — a slide dragged
51307
+ * to rather than pressed for. `cause: "state"` is the bound state asking (a
51308
+ * write from the application, a load on a link, a traversal) rather than
51309
+ * anything done inside the box, and its `event` is null.
51162
51310
  * Answer `false` to REFUSE the change and the slide goes back where it came
51163
51311
  * from — a guard that says no, a session that is gone. A promise refuses it
51164
51312
  * late, once whatever it had to ask has answered; the travel plays meanwhile
51165
51313
  * and is undone if the answer is no.
51166
- * `cause: "url"` is the address asking (see `urlParam`) — a load on a link, a
51167
- * traversal — rather than anything done inside the box.
51314
+ * It is not where the area is REMEMBERED: `signal` does that in both
51315
+ * directions and is written by every way of travelling, while a callback
51316
+ * assigning it catches only the ones it was written for — see
51317
+ * docs/state_binding.md. What is left here is the `cause` and the refusal.
51168
51318
  * @param {"now"|"rest"} [props.commit="now"] - when the change is told.
51169
51319
  * "rest" waits for the travel to be over, and lets the container hold the
51170
51320
  * slide it is going to meanwhile: the picture moves with the finger and the
@@ -51182,33 +51332,6 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
51182
51332
  * one that just travelled there. Called once the travel is over, and in the
51183
51333
  * same render as the return to rest — anything later shows the old content
51184
51334
  * for a frame.
51185
- * @param {string|{name: string, history?: "replace"|"push"}} [props.urlParam]
51186
- * - the search param this container owns, and where it stands is written
51187
- * into: `urlParam="step"` puts `?step=<area>` in the address on every travel,
51188
- * and opens on the area it names on a load, a bookmark, a traversal, a link
51189
- * from anywhere else in the application.
51190
- * It is the answer to "the step should be readable and should survive a
51191
- * reload" WITHOUT one route per slide: no route to declare, no route guard to
51192
- * re-express the walk's own rules in, and no page transition — what travels
51193
- * is the box, and the address is a label on where the box stands. Say it on a
51194
- * container whose slides are places one may be sent to; a walk nobody links
51195
- * into keeps its position to itself.
51196
- * Written by REPLACEMENT: four steps that each stacked an entry would turn
51197
- * one back-press into four, and the back arrow of a page means "leave this
51198
- * page", not "one question back". `history: "push"` says the opposite for the
51199
- * containers that mean it — slides that ARE places one came from, a gallery
51200
- * one browses — and even there a slide reached by dragging replaces, so
51201
- * swiping back and forth does not bury the way out.
51202
- * The address is READ through the walk, not jumped to: it comes from outside
51203
- * the box, so every slide between here and there is asked to let go the way a
51204
- * key going that way would ask it, and the first one that holds
51205
- * (`preventNav`, or a `required` step still unanswered) is where one stops —
51206
- * `?step=done` cannot open a confirmation screen for something nobody sent.
51207
- * Whatever comes of it, the address is then rewritten with the area actually
51208
- * shown: it says where one IS, never where one asked to be. A step the app
51209
- * knows is already answered on a reload is told so by its own `required`
51210
- * (`required={!alreadyFilled}`) — the container remembers nothing across a
51211
- * load, and cannot.
51212
51335
  * Each way of travelling can be shut off, or narrowed to one axis: `true`
51213
51336
  * (every axis the map has), `false`, `"x"`, `"y"`, `"xy"`.
51214
51337
  *
@@ -51261,7 +51384,6 @@ const SlideContainer = ({
51261
51384
  commit = "now",
51262
51385
  loop,
51263
51386
  onLoop,
51264
- urlParam,
51265
51387
  travelByKeyboard = true,
51266
51388
  travelByDrag = true,
51267
51389
  travelByScroll = "x",
@@ -51278,7 +51400,11 @@ const SlideContainer = ({
51278
51400
  const containerRef = useRef();
51279
51401
  // The AREA of the slide being shown, not its rank: a rank would be wrong the
51280
51402
  // moment a slide appears before it, and there is nothing to renumber here.
51281
- const [currentAreaState, setCurrentAreaState] = useState(defaultCurrent);
51403
+ // This is where the container's own answer lives, and it is the only one the
51404
+ // picture is drawn from — an area coming from outside is a REQUEST that has
51405
+ // to be walked to first (see the effect reading currentFromCaller). At mount
51406
+ // there is no road to walk yet, so what the caller holds is taken as it is.
51407
+ const [currentAreaState, setCurrentAreaState] = useState(() => (currentSignal ? currentSignal.peek() : currentProp) ?? defaultCurrent);
51282
51408
  // The slide this container is travelling to while its controller has not been
51283
51409
  // told yet (commit="rest"): for the length of that travel the container is
51284
51410
  // ahead of whoever holds `current`, and this is where it keeps its own answer
@@ -51356,51 +51482,42 @@ const SlideContainer = ({
51356
51482
  // nothing measured per frame.
51357
51483
  const followerElementsRef = useRef([]);
51358
51484
  const currentFromCaller = currentSignal ? currentSignal.value : currentProp;
51359
- const current = rollingArea ?? provisionalArea ?? currentFromCaller ?? currentAreaState;
51360
- // The search param this container owns, when it owns one: the name it is
51361
- // written under, and whether going somewhere is somewhere one CAME from.
51362
- const urlParamName = typeof urlParam === "string" ? urlParam : urlParam?.name;
51363
- const urlParamHistory = urlParam?.history || "replace";
51364
- // What the address says right now. Read from the signal rather than from
51365
- // window.location so that a traversal — the browser's own back and forward —
51366
- // is a render like any other: the address moved, and the slides follow it.
51367
- // Read only when there is a param to read, so a container that owns none is
51368
- // not re-rendered by every navigation in the application.
51369
- const areaInUrl = urlParamName ? new URL(documentUrlSignal.value).searchParams.get(urlParamName) : null;
51370
- // The last thing this container knows the address said, whether it wrote it
51371
- // or read it. What makes a two-way binding stop turning: the address is only
51372
- // an INSTRUCTION when it changed by itself (a load, a traversal, a link), and
51485
+ const current = rollingArea ?? provisionalArea ?? currentAreaState;
51486
+ // The last area this container knows the caller holds, whether it wrote it
51487
+ // there or read it from there. What makes a two-way binding stop turning: an
51488
+ // area held outside is an INSTRUCTION only when it changed by itself (a load,
51489
+ // a traversal, a link, something in the application writing the signal), and
51373
51490
  // what this container put there is not news. Without it, a container ahead of
51374
- // its own URL (commit="rest", where the picture arrives before the address is
51375
- // written) would read its own lateness as an order to go back.
51376
- const areaInUrlSeenRef = useRef(undefined);
51377
- // Where the container stands, said in the address. `replace` amends the entry
51378
- // one is on: a walk of four steps must not turn one back-press into four, and
51379
- // the back arrow of a page keeps meaning "leave this page". `push` is for
51380
- // slides that ARE places one came from — and even there a slide reached by
51381
- // DRAGGING replaces, because browsing back and forth with a thumb is not a
51382
- // trail one wants to walk home along.
51383
- const writeUrlParam = (area, cause, {
51384
- replace = false
51491
+ // its own state (commit="rest", where the picture arrives before the change
51492
+ // is told) would read its own lateness as an order to go back.
51493
+ const areaAskedSeenRef = useRef(currentSignal ? currentSignal.peek() : currentProp);
51494
+ // Where the container stands, said where the caller holds it.
51495
+ //
51496
+ // Whether a slide is a place one came from is the STATE's own business (see
51497
+ // stateSignal's `history`), and this container has no opinion on it. What it
51498
+ // does know is which of its own writes are not places at all, and those are
51499
+ // written by REPLACEMENT whatever the state declares:
51500
+ // - a drag, because browsing back and forth with a thumb is not a trail one
51501
+ // wants to walk home along;
51502
+ // - a correction — a refused travel put back, a walk that stopped before
51503
+ // where it was asked to go — because it is the entry that asked for the
51504
+ // wrong thing being amended, not a place one was;
51505
+ // - the state itself asking (`cause: "state"`), because reading a value back
51506
+ // and answering it is not somewhere one went.
51507
+ const writeAreaAsked = (area, {
51508
+ cause
51385
51509
  } = {}) => {
51386
- if (!urlParamName) {
51510
+ areaAskedSeenRef.current = area;
51511
+ if (!currentSignal) {
51387
51512
  return;
51388
51513
  }
51389
- areaInUrlSeenRef.current = area;
51390
- const urlObject = new URL(window.location.href);
51391
- if (urlObject.searchParams.get(urlParamName) === area) {
51514
+ if (CAUSES_WRITTEN_BY_REPLACEMENT.includes(cause) && currentSignal.set) {
51515
+ currentSignal.set(area, {
51516
+ history: "replace"
51517
+ });
51392
51518
  return;
51393
51519
  }
51394
- urlObject.searchParams.set(urlParamName, area);
51395
- navTo(urlObject.href, {
51396
- replace: replace || urlParamHistory !== "push" || cause === "drag" ||
51397
- // The address already says it: reading it back is not a place one went.
51398
- cause === "url",
51399
- // What travels is the box, not the page: the address is a label on where
51400
- // the box stands, and a route transition would move the whole document
51401
- // for a slide that has already travelled by itself.
51402
- routeTransition: "none"
51403
- });
51520
+ currentSignal.value = area;
51404
51521
  };
51405
51522
  const vertical = layout === "column";
51406
51523
  // What the map has, and what each way of asking is allowed to use of it.
@@ -52031,7 +52148,7 @@ const SlideContainer = ({
52031
52148
  }
52032
52149
  const leftArea = readArea(currentElement);
52033
52150
  setCurrentAreaState(area);
52034
- if (!onCurrentChange && !currentSignal && !urlParamName) {
52151
+ if (!onCurrentChange && !currentSignal) {
52035
52152
  return true;
52036
52153
  }
52037
52154
  // What asked for this, read off the interaction rather than carried down
@@ -52062,21 +52179,16 @@ const SlideContainer = ({
52062
52179
  return true;
52063
52180
  };
52064
52181
 
52065
- // The caller learns where the container went: the bound signal is written,
52066
- // then the address, then `onCurrentChange` is called — in that order, so a
52067
- // caller reading either from inside its own handler reads where it now is.
52068
- // The signal before the address because writing the address can render on
52069
- // the spot (a navigation lets go of what Preact had queued, see
52070
- // rendering_hold.js), and that render must already find `current` moved.
52182
+ // The caller learns where the container went: the bound state is written
52183
+ // first, then `onCurrentChange` is called, so a caller reading the state from
52184
+ // inside its own handler reads where it now is. Written here rather than by
52185
+ // the caller so that it is told about the travels that HAPPENED and about no
52186
+ // others: the ones a lock refused never reach this point, and one refused
52187
+ // late is written back below (see goBackToRefusedArea).
52071
52188
  const tellCurrentChange = (area, detail, leftArea) => {
52072
- if (currentSignal) {
52073
- currentSignal.value = area;
52074
- }
52075
- // The address is told here rather than by the caller so that it is told
52076
- // about the travels that HAPPENED and about no others: the ones a lock
52077
- // refused never reach this point, and one refused late is written back
52078
- // below (see goBackToRefusedArea).
52079
- writeUrlParam(area, detail.cause);
52189
+ writeAreaAsked(area, {
52190
+ cause: detail.cause
52191
+ });
52080
52192
  if (!onCurrentChange) {
52081
52193
  return;
52082
52194
  }
@@ -52104,47 +52216,44 @@ const SlideContainer = ({
52104
52216
  const goBackToRefusedArea = leftArea => {
52105
52217
  setProvisionalArea(null);
52106
52218
  setCurrentAreaState(leftArea);
52107
- if (currentSignal) {
52108
- currentSignal.value = leftArea;
52109
- }
52110
- // Written over rather than stacked on, whatever this container does with
52111
- // the history otherwise: the entry the refused travel wrote is the one
52112
- // being corrected, and a refusal is not a place one was.
52113
- writeUrlParam(leftArea, "code", {
52114
- replace: true
52219
+ // Written over rather than stacked on, whatever the state says otherwise:
52220
+ // the entry a refused travel wrote is the one being corrected, and a
52221
+ // refusal is not a place one was.
52222
+ writeAreaAsked(leftArea, {
52223
+ cause: "refusal"
52115
52224
  });
52116
52225
  };
52117
52226
 
52118
- // The address asks for a slide. Read on every render it changes on rather
52119
- // than at mount alone, because the browser's own arrows are exactly that —
52120
- // the address moving by itself — and a link from elsewhere in the application
52121
- // is too. What follows is a travel like any other: the slides move, the
52122
- // caller is told, and the address ends up saying where the box actually
52123
- // stands.
52227
+ // Something outside the box asks for a slide: the application writing the
52228
+ // bound signal, and — when that signal is a route's own (see
52229
+ // docs/navigation.md) — a load on a link, a traversal, an address typed by
52230
+ // hand. All the same event here, and the answer to all of them is the same:
52231
+ // it is a REQUEST, walked to rather than jumped to.
52232
+ //
52233
+ // Walked, because the request comes from outside and the road may be closed:
52234
+ // every slide between here and there is asked to let go the way a key going
52235
+ // that way would ask it, and the first one that holds is where one stops.
52236
+ // `?step=done` cannot open a confirmation screen for something nobody sent.
52237
+ // Whatever comes of it, the state is then written with the area actually
52238
+ // shown: it says where one IS, never where one asked to be.
52124
52239
  useLayoutEffect(() => {
52125
- if (!urlParamName) {
52240
+ if (currentFromCaller === undefined) {
52126
52241
  return;
52127
52242
  }
52128
- if (areaInUrl === areaInUrlSeenRef.current) {
52243
+ if (currentFromCaller === areaAskedSeenRef.current) {
52129
52244
  // Not news: either nothing moved, or this container is reading back what
52130
52245
  // it wrote itself.
52131
52246
  return;
52132
52247
  }
52133
- areaInUrlSeenRef.current = areaInUrl;
52134
- if (areaInUrl === null) {
52135
- // An address that says nothing is not an address saying "the first
52136
- // slide": a container opened without the param opens where it would have
52137
- // opened anyway, and the param appears the first time one travels.
52138
- return;
52139
- }
52248
+ areaAskedSeenRef.current = currentFromCaller;
52140
52249
  // Where the box IS, read off the DOM: `current` is undefined until someone
52141
52250
  // names a slide, and the container standing on its first one is a fact only
52142
52251
  // the map knows (see the layout effect that paints it).
52143
- const areaOnScreen = containerRef.current?.getAttribute("data-slide-current");
52144
- if (!areaOnScreen || areaInUrl === areaOnScreen) {
52252
+ const areaOnScreen = containerRef.current?.getAttribute(SLIDE_CURRENT_ATTRIBUTE);
52253
+ if (!areaOnScreen || currentFromCaller === areaOnScreen) {
52145
52254
  return;
52146
52255
  }
52147
- const reached = reachableTowards(areaOnScreen, areaInUrl);
52256
+ const reached = reachableTowards(areaOnScreen, currentFromCaller);
52148
52257
  if (reached && reached !== areaOnScreen) {
52149
52258
  // Told rather than asked: the walk above has already put every lock on
52150
52259
  // the way the question goToArea would have put the first one, and this
@@ -52153,15 +52262,25 @@ const SlideContainer = ({
52153
52262
  // any travel nobody pressed anything for.
52154
52263
  setCurrentAreaState(reached);
52155
52264
  tellCurrentChange(reached, {
52156
- cause: "url"
52265
+ cause: "state"
52157
52266
  }, areaOnScreen);
52158
52267
  return;
52159
52268
  }
52160
- // The param names nowhere this map knows, or somewhere the walk is not
52161
- // allowed to reach: the address is put back on the slide one is actually
52162
- // looking at, rather than left saying one is somewhere one is not.
52163
- writeUrlParam(areaOnScreen, "url");
52164
- }, [urlParamName, areaInUrl]);
52269
+ // Nowhere this map knows, or somewhere the walk is not allowed to reach:
52270
+ // the state is put back on the slide one is actually looking at, rather
52271
+ // than left saying one is somewhere one is not. A caller holding `current`
52272
+ // themselves is told instead — it is their value to correct.
52273
+ if (currentSignal) {
52274
+ writeAreaAsked(areaOnScreen, {
52275
+ cause: "refusal"
52276
+ });
52277
+ return;
52278
+ }
52279
+ onCurrentChange?.(areaOnScreen, {
52280
+ cause: "state",
52281
+ event: null
52282
+ });
52283
+ }, [currentFromCaller]);
52165
52284
 
52166
52285
  // The press kept during a roll, taken once the window rests and the travel is
52167
52286
  // given back (noTravel off): by direction when there was one, so it is read
@@ -53259,6 +53378,10 @@ const SlideContainer = ({
53259
53378
  * ahead to a screen that has nothing to show yet. Answering a step
53260
53379
  * un-answers the ones after it, since what they were answered about has just
53261
53380
  * changed.
53381
+ * A container remembers nothing across a load, and cannot: a step the app
53382
+ * knows is already answered when the page opens says so itself
53383
+ * (`required={!alreadyFilled}`), which is what lets an address naming a later
53384
+ * step be walked to.
53262
53385
  * @param {boolean} [props.preventNav] - hold the user here, whichever way they
53263
53386
  * try to leave.
53264
53387
  * @param {boolean} [props.preventNavNext] - hold them from going right or down.
@@ -61346,23 +61469,22 @@ const ListSelectable = props => {
61346
61469
  focusGroupDirection,
61347
61470
  focusGroupWrap
61348
61471
  } = props;
61349
- // What the list holds, which is not the same as what its rows say. A list
61350
- // draws the rows it needs and no more: the selected one may be scrolled out
61351
- // of the window, or filtered out of the view. Aggregating over the rows that
61352
- // happen to be mounted would then lose the selection — a row that is not
61353
- // there cannot say it is not selected.
61354
- const selectionRef = useRef(undefined);
61355
- if (selectionRef.current === undefined) {
61356
- selectionRef.current = Object.hasOwn(props, "value") ? props.value : props.defaultValue;
61357
- }
61472
+ // `kept` is what the list holds as it is asked, which is not the same as what
61473
+ // its rows say: a list draws the rows it needs and no more, so the selected
61474
+ // one may be scrolled out of the window or filtered out of the view, and a
61475
+ // row that is not there cannot say it is not selected. Reading it off the
61476
+ // group rather than remembering it here is what makes a value put ON the list
61477
+ // (a `value` prop, a signal, a reopened popup) replace the whole selection —
61478
+ // a private memory of its own would go on holding the rows it could not see
61479
+ // being unselected.
61480
+ //
61358
61481
  // `fallbackState` is the empty of the shape the list declared below
61359
61482
  // (`stateType`): `[]` for a multiple list, nothing for a single one. Taking
61360
61483
  // it is what lets an emptied list say "empty" — `undefined` is the word for
61361
61484
  // "unset", and a bound stateSignal reads that as "nothing decided here, go
61362
61485
  // back to the default" (see docs/control_value.md), which is how a list
61363
61486
  // emptied down to its last row puts that row back on reload.
61364
- const aggregateChildStates = (children, fallbackState) => {
61365
- const kept = selectionRef.current;
61487
+ const aggregateChildStates = (children, fallbackState, kept) => {
61366
61488
  if (multiple) {
61367
61489
  const drawnValues = new Set(children.map(child => child.props.value));
61368
61490
  const stillSelected = Array.isArray(kept) ? kept.filter(value => !drawnValues.has(value)) : [];
@@ -61371,13 +61493,10 @@ const ListSelectable = props => {
61371
61493
  stillSelected.push(child.uiState);
61372
61494
  }
61373
61495
  }
61374
- const values = stillSelected.length === 0 ? fallbackState : stillSelected;
61375
- selectionRef.current = values;
61376
- return values;
61496
+ return stillSelected.length === 0 ? fallbackState : stillSelected;
61377
61497
  }
61378
61498
  for (const child of children) {
61379
61499
  if (child.uiState !== undefined) {
61380
- selectionRef.current = child.uiState;
61381
61500
  return child.uiState;
61382
61501
  }
61383
61502
  }
@@ -61385,7 +61504,6 @@ const ListSelectable = props => {
61385
61504
  // deselected; if it is not, the list keeps what it holds.
61386
61505
  const keptIsDrawn = children.some(child => child.props.value === kept);
61387
61506
  if (keptIsDrawn) {
61388
- selectionRef.current = undefined;
61389
61507
  return undefined;
61390
61508
  }
61391
61509
  return kept;