@jsenv/navi 0.29.20 → 0.29.22

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,10 +4,10 @@
4
4
  */
5
5
  import { windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, installImportMetaCssBuild, coarsePointerSignal } from "./jsenv_navi_side_effects.js";
6
6
  import { createContext, isValidElement, h, Fragment, toChildArray, render, options, cloneElement } from "preact";
7
- import { useContext, useLayoutEffect, useRef, useEffect, useCallback, useState, useMemo, useId, useErrorBoundary } from "preact/hooks";
7
+ import { useContext, useLayoutEffect, useRef, useCallback, useState, useMemo, useId, useEffect, useErrorBoundary } from "preact/hooks";
8
8
  import { jsx, jsxs, Fragment as Fragment$1 } from "preact/jsx-runtime";
9
9
  import { computed, signal, effect, batch, useSignal } from "@preact/signals";
10
- import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, dispatchInternalCustomEvent, dispatchCustomEvent, findEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, dragAfterThreshold, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
10
+ import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, dispatchInternalCustomEvent, dispatchCustomEvent, findEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, startDragToTravel, scrollRoomTowards, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
11
11
  export { contrastColor, startDragToReorder } from "@jsenv/dom";
12
12
  import { createValidity, parseDuration, durationContainsNaN, compareTwoDurations, durationToSeconds, durationToISOString } from "@jsenv/validity";
13
13
  export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
@@ -1109,6 +1109,10 @@ naviI18n.addAll({
1109
1109
  fr: "Ce champ doit contenir au maximum [max] caractères (il contient actuellement [count] caractères).",
1110
1110
  en: "This field must contain at most [max] characters (it currently contains [count] characters).",
1111
1111
  },
1112
+ "constraint.max_length.selection": {
1113
+ fr: "Sélectionnez au maximum [max] choix ([count] actuellement).",
1114
+ en: "Select at most [max] choices ([count] currently).",
1115
+ },
1112
1116
  "constraint.type.number.default": {
1113
1117
  fr: "Ce champ doit être un nombre.",
1114
1118
  en: "This field must be a number.",
@@ -1361,6 +1365,11 @@ naviI18n.addAll({
1361
1365
  fr: "Ce champ ne peut pas contenir plus de [max] caractère[s], une partie a été tronquée.",
1362
1366
  en: "This field cannot contain more than [max] character[s]; the value was truncated.",
1363
1367
  },
1368
+ // maxLengthGuard on a multiple selection: one more item would exceed the limit
1369
+ "constraint.guard.max_length.selection": {
1370
+ fr: "[max] max.",
1371
+ en: "[max] max.",
1372
+ },
1364
1373
  });
1365
1374
 
1366
1375
  // Date/time placeholder tokens — shown when no value is selected
@@ -9169,7 +9178,7 @@ const useComposeElementRef = (syncElement, externalRef) => {
9169
9178
  const refCallback = (el) => {
9170
9179
  elRef.current = el;
9171
9180
  // Keep .current in sync immediately so useEffect callbacks that read
9172
- // ref.current (e.g. usePartiallyHidden) see the element, not null.
9181
+ // ref.current see the element, not null.
9173
9182
  refCallback.current = el;
9174
9183
  const currentExternalRef = externalRefRef.current;
9175
9184
  if (currentExternalRef) {
@@ -9197,54 +9206,6 @@ const useComposeElementRef = (syncElement, externalRef) => {
9197
9206
  return refCallback;
9198
9207
  };
9199
9208
 
9200
- /**
9201
- * Tracks whether an element is fully visible in its scroll container and sets
9202
- * the `navi-partially-hidden` attribute when any part of it is clipped.
9203
- *
9204
- * This is used to suppress `view-transition-name` on elements that are partially
9205
- * outside the viewport or a scrollable container. Without this, a partially clipped
9206
- * element would still participate in view transitions, producing ghost animations or
9207
- * incorrect cross-fade effects.
9208
- *
9209
- * CSS usage:
9210
- * ```css
9211
- * [navi-partially-hidden] {
9212
- * view-transition-name: none !important;
9213
- * }
9214
- * ```
9215
- *
9216
- * `Box` enables this hook automatically when a `viewTransitionName` prop is provided.
9217
- *
9218
- * @param {import("preact").RefObject} ref - Ref to the element to observe.
9219
- * @param {boolean} enabled - Only observe when true (typically when view-transition-name is set).
9220
- */
9221
- const usePartiallyHidden = (ref, enabled) => {
9222
- useEffect(() => {
9223
- const el = ref.current;
9224
- if (!el || !enabled) {
9225
- return undefined;
9226
- }
9227
- return setupPartiallyHidden(el);
9228
- }, [enabled]);
9229
- };
9230
-
9231
- const setupPartiallyHidden = (el) => {
9232
- const observer = new IntersectionObserver(
9233
- ([entry]) => {
9234
- if (entry.intersectionRatio >= 0.99) {
9235
- el.removeAttribute("navi-partially-hidden");
9236
- } else {
9237
- el.setAttribute("navi-partially-hidden", "");
9238
- }
9239
- },
9240
- { threshold: 0.99 },
9241
- );
9242
- observer.observe(el);
9243
- return () => {
9244
- observer.disconnect();
9245
- };
9246
- };
9247
-
9248
9209
  installImportMetaCssBuild(import.meta);/**
9249
9210
  * Box - A Swiss Army Knife for Layout
9250
9211
  *
@@ -9477,12 +9438,6 @@ import.meta.css = [/* css */`
9477
9438
  [hidden] {
9478
9439
  display: none !important;
9479
9440
  }
9480
-
9481
- /* Partially hidden (or fully hidden) element should not participate in view transition no matter what */
9482
- /* Otherwise they appear immedatly and fully visible from a fully/partially hidden state */
9483
- [navi-partially-hidden] {
9484
- view-transition-name: none !important;
9485
- }
9486
9441
  `, "@jsenv/navi/src/box/box.jsx"];
9487
9442
  const PSEUDO_CLASSES_DEFAULT = [];
9488
9443
  const PSEUDO_ELEMENTS_DEFAULT = [];
@@ -9984,7 +9939,6 @@ const Box = props => {
9984
9939
  });
9985
9940
  }, styleDeps);
9986
9941
  finalRef = useComposeElementRef(syncBox, ref);
9987
- usePartiallyHidden(finalRef, Boolean(rest.viewTransitionName));
9988
9942
  }
9989
9943
  let innerChildren = children;
9990
9944
  if (separator) {
@@ -10166,7 +10120,7 @@ const setupNetworkMonitoring = () => {
10166
10120
  };
10167
10121
  setupNetworkMonitoring();
10168
10122
 
10169
- installImportMetaCssBuild(import.meta);const css$Z = /* css */`
10123
+ installImportMetaCssBuild(import.meta);const css$_ = /* css */`
10170
10124
  .navi_loading_indicator_fluid_container {
10171
10125
  position: relative;
10172
10126
  display: flex;
@@ -10198,7 +10152,7 @@ const LoadingIndicatorFluid = ({
10198
10152
  visuallyHidden,
10199
10153
  ...rest
10200
10154
  }) => {
10201
- import.meta.css = [css$Z, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
10155
+ import.meta.css = [css$_, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
10202
10156
  const ref = useRef(null);
10203
10157
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
10204
10158
  const [containerWidth, setContainerWidth] = useState(0);
@@ -10403,7 +10357,7 @@ const LoadingRectangleSvg = ({
10403
10357
  });
10404
10358
  };
10405
10359
 
10406
- installImportMetaCssBuild(import.meta);const css$Y = /* css */`
10360
+ installImportMetaCssBuild(import.meta);const css$Z = /* css */`
10407
10361
  .navi_loading_outline_wrapper {
10408
10362
  position: absolute;
10409
10363
  /* Controls place the outline slightly outside their box, right on top of
@@ -10440,7 +10394,7 @@ installImportMetaCssBuild(import.meta);const css$Y = /* css */`
10440
10394
  }
10441
10395
  `;
10442
10396
  const LoadingOutline = props => {
10443
- import.meta.css = [css$Y, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
10397
+ import.meta.css = [css$Z, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
10444
10398
  if (props.containerRef) {
10445
10399
  const container = props.containerRef.current;
10446
10400
  if (!container) {
@@ -10774,7 +10728,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
10774
10728
  };
10775
10729
 
10776
10730
  installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
10777
- const css$X = /* css */`
10731
+ const css$Y = /* css */`
10778
10732
  @layer navi {
10779
10733
  .navi_text {
10780
10734
  &[data-skeleton] {
@@ -11280,7 +11234,7 @@ const TextShrinkWrap = props => {
11280
11234
  });
11281
11235
  };
11282
11236
  const TextUI = props => {
11283
- import.meta.css = [css$X, "@jsenv/navi/src/text/text.jsx"];
11237
+ import.meta.css = [css$Y, "@jsenv/navi/src/text/text.jsx"];
11284
11238
  let {
11285
11239
  ref,
11286
11240
  spacing,
@@ -13707,7 +13661,7 @@ installImportMetaCssBuild(import.meta);/**
13707
13661
  * - Arrow automatically shows when pointing at a valid anchor element
13708
13662
  * - Centers in viewport when no anchor element provided or anchor is too big
13709
13663
  */
13710
- const css$W = /* css */`
13664
+ const css$X = /* css */`
13711
13665
  @layer navi {
13712
13666
  .navi_callout {
13713
13667
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -13946,7 +13900,7 @@ const openCallout = (message, {
13946
13900
  skipFocus = false,
13947
13901
  debug = () => {}
13948
13902
  } = {}) => {
13949
- import.meta.css = [css$W, "@jsenv/navi/src/control/rules/callout/callout.js"];
13903
+ import.meta.css = [css$X, "@jsenv/navi/src/control/rules/callout/callout.js"];
13950
13904
  if (debug === true) {
13951
13905
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
13952
13906
  }
@@ -16607,6 +16561,31 @@ const MAX_LENGTH_CONSTRAINT = {
16607
16561
  messageAttribute: "data-max-length-message",
16608
16562
  check: (field) => {
16609
16563
  const type = field.controlHostProps.type ?? "text";
16564
+ // A multiple selection has a length the way a string has one — how many
16565
+ // items it holds — so it wears the same constraint under the same name. The
16566
+ // group answers for it: a checkbox on its own holds one value, not a count.
16567
+ if (field.controlType === "checkbox_group") {
16568
+ const maxLength =
16569
+ field.controlHostProps.maxLength ?? field.props?.maxLengthGuard;
16570
+ if (maxLength === undefined) {
16571
+ return null;
16572
+ }
16573
+ const uiState = field.uiState;
16574
+ if (!Array.isArray(uiState)) {
16575
+ return null;
16576
+ }
16577
+ const count = uiState.length;
16578
+ if (count <= maxLength) {
16579
+ return null;
16580
+ }
16581
+ return {
16582
+ message: naviI18n("constraint.max_length.selection", {
16583
+ max: String(maxLength),
16584
+ count: String(count),
16585
+ }),
16586
+ target: field.ref.current,
16587
+ };
16588
+ }
16610
16589
  const isInput =
16611
16590
  field.controlType === "input" || field.controlType === "picker";
16612
16591
  const isTextarea =
@@ -18144,6 +18123,23 @@ const READONLY_CONSTRAINT = {
18144
18123
  return null;
18145
18124
  }
18146
18125
 
18126
+ // A selection guarding its length (see maxLengthGuard) is what holds this
18127
+ // one back, so max_length is what refuses it: same name, same message, same
18128
+ // `maxLengthMessage` to say it in the caller's own words. Read-only is only
18129
+ // how it is expressed on the item.
18130
+ const parent = field.parentUIStateController;
18131
+ if (parent?.isChildBlockedByMaxLengthGuard?.(field)) {
18132
+ return {
18133
+ name: MAX_LENGTH_CONSTRAINT.name,
18134
+ constraint: MAX_LENGTH_CONSTRAINT,
18135
+ message: naviI18n("constraint.guard.max_length.selection", {
18136
+ max: String(parent.props.maxLengthGuard),
18137
+ }),
18138
+ status: "info",
18139
+ ignoredByParents: true,
18140
+ };
18141
+ }
18142
+
18147
18143
  // A readonly element does not block its parent from submitting — mirrors
18148
18144
  // standard HTML form behaviour where readonly inputs are submitted as-is.
18149
18145
  return {
@@ -21426,6 +21422,25 @@ const useUIGroupStateController = (
21426
21422
  onActionError: (e) => {
21427
21423
  controller.rules.validation.syncValidity(e, { report: true });
21428
21424
  },
21425
+ // Whether `maxLengthGuard` stands between this child and the selection.
21426
+ // A guard on the gesture only: what the group already holds is left
21427
+ // alone, however long, and a selected child is never blocked — it must
21428
+ // stay takeable back. Read off the signal so the other children learn
21429
+ // about the group filling up and emptying again.
21430
+ isChildBlockedByMaxLengthGuard: (childUIStateController) => {
21431
+ const { maxLengthGuard } = controller.props;
21432
+ if (maxLengthGuard === undefined) {
21433
+ return false;
21434
+ }
21435
+ if (childUIStateController.uiState !== undefined) {
21436
+ return false;
21437
+ }
21438
+ const uiState = uiStateSignal.value;
21439
+ if (!Array.isArray(uiState)) {
21440
+ return false;
21441
+ }
21442
+ return uiState.length >= maxLengthGuard;
21443
+ },
21429
21444
  findChildById: (searchId) => {
21430
21445
  for (const c of childUIStateControllerArray) {
21431
21446
  if (c.id === searchId) return c;
@@ -22779,6 +22794,10 @@ const useControlgroupProps = (props, {
22779
22794
  // useful to children, not the the group itself
22780
22795
  "required": undefined,
22781
22796
  // useful to children, not the the group itself
22797
+ // How many items the group accepts, read by its controller and by the
22798
+ // children asking whether there is still room for them. Not an attribute
22799
+ // any element wears: a <fieldset maxlength> means nothing.
22800
+ "maxLength": undefined,
22782
22801
  "onnavi_action_allowed": e => {
22783
22802
  setActionRequester(e.detail.requester);
22784
22803
  controlgroupProps.onnavi_action_allowed(e);
@@ -22961,7 +22980,12 @@ const useInteractiveProps = (props, {
22961
22980
  // on its own say-so.
22962
22981
  const loadingFromParent = Boolean(controlLoading && parentActionRequester === ref.current);
22963
22982
  const loadingBase = loading || loadingFromParent;
22964
- const readOnlyBase = readOnly || controlReadOnly || loadingBase || controlInfo.readOnlyUncontrolled;
22983
+ // Read-only because the selection above guards its length
22984
+ // (`maxLengthGuard`) and this one would make it longer: it can be pointed
22985
+ // at, focused and pressed — and answers why (see readonly_constraint.js) —
22986
+ // but cannot be taken.
22987
+ const readOnlyFromParentMaxLengthGuard = Boolean(uiStateController.parentUIStateController?.isChildBlockedByMaxLengthGuard?.(uiStateController));
22988
+ const readOnlyBase = readOnly || controlReadOnly || loadingBase || readOnlyFromParentMaxLengthGuard || controlInfo.readOnlyUncontrolled;
22965
22989
  const loadingResolved = loadingBase || actionStatus.loading;
22966
22990
  const readOnlyResolved = readOnlyBase || actionStatus.loading;
22967
22991
  // Both halves of "busy" that do not come from the bound action, kept apart
@@ -23295,7 +23319,7 @@ const getAssociatedLabels = element => {
23295
23319
  return Array.from(element.labels);
23296
23320
  };
23297
23321
 
23298
- installImportMetaCssBuild(import.meta);const css$V = /* css */`
23322
+ installImportMetaCssBuild(import.meta);const css$W = /* css */`
23299
23323
  @layer navi {
23300
23324
  .navi_button {
23301
23325
  --button-border-radius: var(--navi-control-border-radius);
@@ -23688,7 +23712,7 @@ installImportMetaCssBuild(import.meta);const css$V = /* css */`
23688
23712
  }
23689
23713
  `;
23690
23714
  const ButtonUI = props => {
23691
- import.meta.css = [css$V, "@jsenv/navi/src/control/input/button_ui.jsx"];
23715
+ import.meta.css = [css$W, "@jsenv/navi/src/control/input/button_ui.jsx"];
23692
23716
  const {
23693
23717
  ref,
23694
23718
  // href/link
@@ -25902,7 +25926,7 @@ installImportMetaCssBuild(import.meta);/**
25902
25926
  * reaches the real container.
25903
25927
  */
25904
25928
  let openLocalDialogCount = 0;
25905
- const css$U = /* css */`
25929
+ const css$V = /* css */`
25906
25930
  @layer navi {
25907
25931
  .navi_dialog {
25908
25932
  /* Min gap between the dialog and the edges of its container. Written
@@ -26317,7 +26341,7 @@ const css$U = /* css */`
26317
26341
  * @param {import("ignore:preact").ComponentChildren} props.children
26318
26342
  */
26319
26343
  const Dialog = props => {
26320
- import.meta.css = [css$U, "@jsenv/navi/src/layout/dialog.jsx"];
26344
+ import.meta.css = [css$V, "@jsenv/navi/src/layout/dialog.jsx"];
26321
26345
  if (props.openController) {
26322
26346
  return jsx(ControlledDialog, {
26323
26347
  ...props
@@ -27150,7 +27174,7 @@ installImportMetaCssBuild(import.meta);/**
27150
27174
  * and applied.
27151
27175
  */
27152
27176
  let openLocalPopoverCount = 0;
27153
- const css$T = /* css */`
27177
+ const css$U = /* css */`
27154
27178
  @layer navi {
27155
27179
  .navi_popover {
27156
27180
  /* soft: user-configurable preferred max-height. Kept as a *default*
@@ -27525,7 +27549,7 @@ const css$T = /* css */`
27525
27549
  * @param {import("ignore:preact").ComponentChildren} props.children
27526
27550
  */
27527
27551
  const Popover = props => {
27528
- import.meta.css = [css$T, "@jsenv/navi/src/layout/popover.jsx"];
27552
+ import.meta.css = [css$U, "@jsenv/navi/src/layout/popover.jsx"];
27529
27553
  if (props.openController) {
27530
27554
  return jsx(ControlledPopover, {
27531
27555
  ...props
@@ -28494,7 +28518,7 @@ installImportMetaCssBuild(import.meta);/**
28494
28518
  * event, and a caller replacing the body entirely then has one protocol to
28495
28519
  * follow — `--navi-confirm` for yes, anything that closes for no.
28496
28520
  */
28497
- const css$S = /* css */`
28521
+ const css$T = /* css */`
28498
28522
  /* The width lives on the body rather than on the popup, so that custom
28499
28523
  content (which replaces this body entirely) sizes itself instead of
28500
28524
  inheriting a ceiling meant for a sentence-long question. */
@@ -28631,7 +28655,7 @@ const ConfirmPopup = ({
28631
28655
  onAnswer,
28632
28656
  onClosed
28633
28657
  }) => {
28634
- import.meta.css = [css$S, "@jsenv/navi/src/action/confirm_popup.jsx"];
28658
+ import.meta.css = [css$T, "@jsenv/navi/src/action/confirm_popup.jsx"];
28635
28659
  const {
28636
28660
  mode,
28637
28661
  confirmLabel,
@@ -28715,7 +28739,7 @@ const defaultBody = (message, {
28715
28739
  });
28716
28740
  };
28717
28741
 
28718
- installImportMetaCssBuild(import.meta);const css$R = /* css */`
28742
+ installImportMetaCssBuild(import.meta);const css$S = /* css */`
28719
28743
  .action_error {
28720
28744
  margin-top: 0;
28721
28745
  margin-bottom: 20px;
@@ -28740,7 +28764,7 @@ const ActionRenderer = ({
28740
28764
  children,
28741
28765
  disabled
28742
28766
  }) => {
28743
- import.meta.css = [css$R, "@jsenv/navi/src/action/action_renderer.jsx"];
28767
+ import.meta.css = [css$S, "@jsenv/navi/src/action/action_renderer.jsx"];
28744
28768
  if (action === undefined) {
28745
28769
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
28746
28770
  }
@@ -30198,6 +30222,109 @@ const getParamScope = (params) => {
30198
30222
  return newParamScope;
30199
30223
  };
30200
30224
 
30225
+ /*
30226
+ * GET_PAGE: reading a resource one slice at a time, for a list that draws its
30227
+ * rows as it goes (`<List.Items itemsAction>`).
30228
+ *
30229
+ * It is on purpose not an action. An action keeps the one response it got and
30230
+ * replays it, and takes a place in the rerun graph — two things a slice must
30231
+ * not do: the list already holds the slices it received and glues them back
30232
+ * together, and a mutation would otherwise send every slice ever loaded back to
30233
+ * the network at once. So the reader keeps nothing. It runs the callback with
30234
+ * the range the list asks for, writes what comes back into the store, and hands
30235
+ * the rows back as store items — never a copy of the JSON, so the relations a
30236
+ * row reads are the shared ones and a request sent from a row is read back on
30237
+ * it. A row following its own fields through a write reads them from the store
30238
+ * (`RESOURCE.useById(id)`): an update replaces the item object, and the one the
30239
+ * list is holding is the one it was given.
30240
+ *
30241
+ * The reader is a function, so a list feeds on it the way it feeds on any other
30242
+ * source: `itemsAction={GAME.GET_PAGE.bindParams({ radar })}`.
30243
+ */
30244
+
30245
+
30246
+ const createPageReader = (
30247
+ actionName,
30248
+ callback,
30249
+ { store, params: boundParams },
30250
+ ) => {
30251
+ const readPage = async (range = {}) => {
30252
+ const { signal, ...rangeParams } = range;
30253
+ const paramsResolved = { ...resolveParams(boundParams), ...rangeParams };
30254
+ const result = await callback(paramsResolved, { signal });
30255
+ if (!result || !Array.isArray(result.items)) {
30256
+ throw new TypeError(
30257
+ `${actionName} must return { items, start, count }, received ${describeResult(result)}.`,
30258
+ );
30259
+ }
30260
+ const items = store.upsert(result.items);
30261
+ let { start, count } = result;
30262
+ if (start === undefined) {
30263
+ const startAsked = rangeParams.start;
30264
+ if (startAsked === undefined || startAsked < 0) {
30265
+ throw new TypeError(
30266
+ `${actionName} must say where the page lands (start), it was asked for ${describeRangeAsked(rangeParams)}.`,
30267
+ );
30268
+ }
30269
+ start = startAsked;
30270
+ }
30271
+ if (count === undefined) {
30272
+ count = start + items.length;
30273
+ }
30274
+ return { items, start, count };
30275
+ };
30276
+ Object.defineProperty(readPage, "name", { value: actionName });
30277
+ readPage.isPageReader = true;
30278
+ readPage.bindParams = (paramsToBind) => {
30279
+ return createPageReader(actionName, callback, {
30280
+ store,
30281
+ params: boundParams ? { ...boundParams, ...paramsToBind } : paramsToBind,
30282
+ });
30283
+ };
30284
+ return readPage;
30285
+ };
30286
+
30287
+ // Params bound to a reader may be signals (the radar currently on screen); the
30288
+ // value they hold when the page is asked for is the one the page is about.
30289
+ const resolveParams = (params) => {
30290
+ if (!params) {
30291
+ return {};
30292
+ }
30293
+ const paramsResolved = {};
30294
+ for (const key of Object.keys(params)) {
30295
+ const value = params[key];
30296
+ paramsResolved[key] = isSignal(value) ? value.value : value;
30297
+ }
30298
+ return paramsResolved;
30299
+ };
30300
+
30301
+ const describeResult = (result) => {
30302
+ if (Array.isArray(result)) {
30303
+ return `an array of ${result.length} item${result.length === 1 ? "" : "s"}`;
30304
+ }
30305
+ if (result && typeof result === "object") {
30306
+ return `an object holding ${Object.keys(result).join(", ") || "nothing"}`;
30307
+ }
30308
+ return `${result}`;
30309
+ };
30310
+
30311
+ const describeRangeAsked = (rangeParams) => {
30312
+ const { start, limit, around, before, after } = rangeParams;
30313
+ if (around !== undefined) {
30314
+ return `the rows around "${around}"`;
30315
+ }
30316
+ if (before !== undefined) {
30317
+ return `the ${limit} rows before "${before}"`;
30318
+ }
30319
+ if (after !== undefined) {
30320
+ return `the ${limit} rows after "${after}"`;
30321
+ }
30322
+ if (start < 0) {
30323
+ return `the last ${limit} rows`;
30324
+ }
30325
+ return `${limit} rows from ${start}`;
30326
+ };
30327
+
30201
30328
  const resourceLifecycleManager = createResourceLifecycleManager();
30202
30329
  const debug$2 = (args) => {
30203
30330
  {
@@ -30216,13 +30343,18 @@ const debug$2 = (args) => {
30216
30343
  * - GET / POST / PUT / PATCH → the full item object, e.g. `{ id, name }`
30217
30344
  * - DELETE → the id or `{ id }` of the removed item
30218
30345
  * - GET_MANY / POST_MANY / … → an array of item objects
30346
+ * - GET_PAGE → `{ items, start, count }`, one slice of the collection
30347
+ *
30348
+ * `GET_PAGE` is a reader rather than an action: it keeps no value and takes no place in
30349
+ * the rerun graph, so a `<List.Items>` can feed on it slice by slice
30350
+ * (`itemsAction={USER.GET_PAGE.bindParams({ team })}`).
30219
30351
  *
30220
30352
  * A sub-resource of the backend (`/games/:id/candidates`) must be modelled with a
30221
30353
  * relationship method, never as an `op`/`type` discriminator dispatched inside one
30222
30354
  * verb's callback.
30223
30355
  *
30224
30356
  * @param {string} name - resource name, used in action names and error messages
30225
- * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30357
+ * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, GET_PAGE, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30226
30358
  * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"` (or the first `uniqueKeys` entry)
30227
30359
  * @param {string[]} [restCallbacks.uniqueKeys] - alternate keys the store can find an item by (e.g. `"username"`); a callback may return a different `id` to rename the item's primary key
30228
30360
  * @see docs/resource.md — relationships, callback return contracts, decision table
@@ -30246,6 +30378,7 @@ const resource = (
30246
30378
 
30247
30379
  GET,
30248
30380
  GET_MANY,
30381
+ GET_PAGE,
30249
30382
  POST,
30250
30383
  POST_MANY,
30251
30384
  PUT,
@@ -30311,6 +30444,7 @@ const resource = (
30311
30444
  restCallbacks: {
30312
30445
  GET,
30313
30446
  GET_MANY,
30447
+ GET_PAGE,
30314
30448
  POST,
30315
30449
  POST_MANY,
30316
30450
  PUT,
@@ -31392,6 +31526,16 @@ ${originalActionName} source location: ${locationInfo}`,
31392
31526
  if (restCallback === undefined) {
31393
31527
  continue;
31394
31528
  }
31529
+ if (restCallbackKey === "GET_PAGE") {
31530
+ // A page is read, never kept: no action, no place in the rerun graph
31531
+ // (see resource_page_reader.js).
31532
+ stateFacade.GET_PAGE = createPageReader(
31533
+ `${name}.GET_PAGE`,
31534
+ restCallback,
31535
+ { store, params },
31536
+ );
31537
+ continue;
31538
+ }
31395
31539
  const isMany = restCallbackKey.endsWith("_MANY");
31396
31540
  const verb = isMany
31397
31541
  ? restCallbackKey.replace("_MANY", "")
@@ -32390,7 +32534,7 @@ const swapArrayItemByIndex = (array, indexA, indexB) => {
32390
32534
  */
32391
32535
  const ensureDocumentStartViewTransition = () => {
32392
32536
  if (!document.startViewTransition) {
32393
- document.startViewTransition = (updateCallback) => {
32537
+ const startViewTransitionPolyfill = (updateCallback) => {
32394
32538
  updateCallback();
32395
32539
  return {
32396
32540
  updateCallbackDone: Promise.resolve(),
@@ -32399,11 +32543,41 @@ const ensureDocumentStartViewTransition = () => {
32399
32543
  skipTransition: () => {},
32400
32544
  };
32401
32545
  };
32402
- }
32403
- return startViewTransition;
32546
+ // Said out loud, because the difference matters to whoever needs the
32547
+ // transition itself rather than the change: there is no picture of the
32548
+ // state being left here, so nothing can be animated between the two — and
32549
+ // once this is installed, asking the document is no longer a way to know.
32550
+ startViewTransitionPolyfill.isPolyfill = true;
32551
+ document.startViewTransition = startViewTransitionPolyfill;
32552
+ }
32553
+ return startViewTransition$1;
32554
+ };
32555
+
32556
+ // A transition a finger is holding still (see route_travel.jsx): it has to be
32557
+ // let go of before any other one starts, and this is the only place that knows
32558
+ // a new one is about to. There is a single transition per document — starting
32559
+ // one SKIPS the one in flight — and the hold that keeps pictures under a finger
32560
+ // is written against whatever transition is running, because everything the
32561
+ // gesture must carry along (a trait under a tab row) belongs to that same
32562
+ // transition and has no name of its own here. Left on, it takes hold of the
32563
+ // transition that has just replaced ours: born paused, held by nobody, it never
32564
+ // finishes, and its pictures stand over a page that cannot be touched anymore.
32565
+ let releaseHeldViewTransition = null;
32566
+ const holdViewTransition = (release) => {
32567
+ releaseHeldViewTransition = release;
32568
+ return () => {
32569
+ if (releaseHeldViewTransition === release) {
32570
+ releaseHeldViewTransition = null;
32571
+ }
32572
+ };
32404
32573
  };
32405
32574
 
32406
- const startViewTransition = (updateCallback) => {
32575
+ const startViewTransition$1 = (updateCallback) => {
32576
+ if (releaseHeldViewTransition) {
32577
+ const release = releaseHeldViewTransition;
32578
+ releaseHeldViewTransition = null;
32579
+ release();
32580
+ }
32407
32581
  const viewTransition = document.startViewTransition(updateCallback);
32408
32582
  viewTransition.updateCallbackDone.catch(ignoreSkip);
32409
32583
  viewTransition.ready.catch(ignoreSkip);
@@ -33602,6 +33776,29 @@ const updateDocumentState = (value) => {
33602
33776
  documentStateSignal.value = value;
33603
33777
  };
33604
33778
 
33779
+ /**
33780
+ * A navigation is ABOUT to be applied — said before its very first write.
33781
+ *
33782
+ * Everything else a router says arrives once the change is made: a route
33783
+ * announces that it matches, an action that it is running. That is too late for
33784
+ * anyone who needs the page as it stands BEFORE, and the browser's view
33785
+ * transitions are exactly that kind of reader — the picture they keep of the
33786
+ * page being left is taken at the next frame, and a render answering a signal
33787
+ * written a moment ago is already in the DOM by then (see route_travel.jsx).
33788
+ *
33789
+ * So this is the one moment where nothing has moved yet. It is published
33790
+ * synchronously, from the top of the navigation, and whoever listens runs
33791
+ * before the URL, the visited set, or any route has changed.
33792
+ *
33793
+ * The other end is published too, and for the same kind of reader: whoever
33794
+ * held something across the change and has nobody to hand it to gets a moment
33795
+ * to let go of it that does not depend on guessing how long the change takes.
33796
+ */
33797
+
33798
+
33799
+ const [publishBeforeRouting, observeBeforeRouting] = createPubSub();
33800
+ const [publishAfterRouting, observeAfterRouting] = createPubSub();
33801
+
33605
33802
  const setupBrowserIntegrationViaHistory = ({
33606
33803
  applyActions,
33607
33804
  applyRouting,
@@ -33653,6 +33850,19 @@ const setupBrowserIntegrationViaHistory = ({
33653
33850
 
33654
33851
  let abortController = null;
33655
33852
  const handleRoutingTask = (url, options) => {
33853
+ // Before anything is written: the visited set, the URL and every route are
33854
+ // about to change, and this is the last moment the page still stands as it
33855
+ // was. And after, whichever way the change went out — so that whoever took
33856
+ // something at the first announcement has a definite place to give it back.
33857
+ publishBeforeRouting({ url, ...options });
33858
+ try {
33859
+ return applyRoutingTask(url, options);
33860
+ } finally {
33861
+ publishAfterRouting({ url, ...options });
33862
+ }
33863
+ };
33864
+
33865
+ const applyRoutingTask = (url, options) => {
33656
33866
  const isSameUrl = url === window.location.href;
33657
33867
  const {
33658
33868
  reason,
@@ -34222,6 +34432,30 @@ const Head = ({
34222
34432
  * ```
34223
34433
  */
34224
34434
 
34435
+ const [publishRouteRender, observeRouteRender] = createPubSub();
34436
+
34437
+ /**
34438
+ * Keep every container showing the page it is showing, whatever the routes say.
34439
+ *
34440
+ * For a caller whose picture of a page is LIVE and must not follow the router:
34441
+ * a travel being undone shows the page it is going back to on both sides at
34442
+ * once if the router is allowed to swap under it (see route_travel.jsx). Only
34443
+ * the pages are held still — the rest of the document goes on rendering, which
34444
+ * is the whole reason this lives here rather than in Preact's own scheduler.
34445
+ */
34446
+ let routeRenderFrozen = false;
34447
+ const routeRenderFrozenSignal = signal(0);
34448
+ const freezeRouteRender = () => {
34449
+ routeRenderFrozen = true;
34450
+ return () => {
34451
+ if (!routeRenderFrozen) {
34452
+ return;
34453
+ }
34454
+ routeRenderFrozen = false;
34455
+ // Read during every container's render, so letting go brings them all back.
34456
+ routeRenderFrozenSignal.value++;
34457
+ };
34458
+ };
34225
34459
  const debug$1 = (...args) => {
34226
34460
  {
34227
34461
  return;
@@ -34242,9 +34476,51 @@ const Route = props => {
34242
34476
  ...props
34243
34477
  });
34244
34478
  };
34479
+ /**
34480
+ * The routes a tree of <Route> children is made of, in the order they are
34481
+ * written. Reading them is what turns a router into a row one can walk: "one
34482
+ * step that way" is a fact about the order the branches were declared in, and
34483
+ * nothing in a URL says it.
34484
+ *
34485
+ * The same walk the container does to find the active branch (collectBranches),
34486
+ * except that it keeps every leaf rather than the one that matches — and reads
34487
+ * no signal, so asking does not subscribe the asker to anything.
34488
+ */
34489
+ const collectRoutes = children => {
34490
+ const routes = [];
34491
+ const visit = child => {
34492
+ if (!child || child === true || child === false) {
34493
+ return;
34494
+ }
34495
+ if (Array.isArray(child)) {
34496
+ for (const item of child) {
34497
+ visit(item);
34498
+ }
34499
+ return;
34500
+ }
34501
+ if (child.type !== Route) {
34502
+ return;
34503
+ }
34504
+ const {
34505
+ children: nodeChildren,
34506
+ route
34507
+ } = child.props;
34508
+ if (nodeChildren) {
34509
+ visit(nodeChildren);
34510
+ return;
34511
+ }
34512
+ if (route) {
34513
+ routes.push(route);
34514
+ }
34515
+ };
34516
+ visit(children);
34517
+ return routes;
34518
+ };
34519
+
34245
34520
  // RouteContainer: traverses children statically per render, finds the active branch,
34246
34521
  // and renders only that branch — or the fallback if nothing matches.
34247
- // No effects, no signals, no contexts needed: reads route signals directly.
34522
+ // No contexts, no state of its own: it reads the route signals directly, and
34523
+ // its one effect only tells the outside what it has just done.
34248
34524
  const RouteContainer = ({
34249
34525
  id,
34250
34526
  element,
@@ -34254,8 +34530,24 @@ const RouteContainer = ({
34254
34530
  const {
34255
34531
  activeBranch
34256
34532
  } = collectBranches(children);
34257
- debug$1(`[container "${id}"] RENDER, active=${activeBranch ? activeBranch.type : "none"}`);
34258
- const content = activeBranch ? activeBranch.node : null;
34533
+
34534
+ // Told to hold still: what is on screen stays on screen. Kept as the very
34535
+ // vnode that was rendered last time, which is how Preact is told there is
34536
+ // nothing to look at in that subtree.
34537
+ const frozen = routeRenderFrozenSignal.value >= 0 && routeRenderFrozen;
34538
+ const shownBranchRef = useRef(null);
34539
+ if (!frozen) {
34540
+ shownBranchRef.current = activeBranch;
34541
+ }
34542
+ const branch = shownBranchRef.current || activeBranch;
34543
+
34544
+ // The one effect here, and it says the only thing this component knows that
34545
+ // nobody outside can find out: the branch it chose is now in the DOM.
34546
+ useLayoutEffect(() => {
34547
+ publishRouteRender();
34548
+ });
34549
+ debug$1(`[container "${id}"] RENDER, active=${branch ? branch.type : "none"}`);
34550
+ const content = branch ? branch.node : null;
34259
34551
  if (!content) {
34260
34552
  return null;
34261
34553
  }
@@ -34376,6 +34668,1219 @@ const RouteUI = ({
34376
34668
  return element;
34377
34669
  };
34378
34670
 
34671
+ installImportMetaCssBuild(import.meta);/**
34672
+ * Dragging from one route to the next, when the tabs of a page are URLs.
34673
+ *
34674
+ * A swipe shows two pages at once, and the router shows one: it mounts the
34675
+ * branch that matches the URL and nothing else, which is what makes a page
34676
+ * shareable, reloadable and findable in the history. Both are right, so the
34677
+ * second picture is not taken from the DOM — it is taken from the SCREEN. The
34678
+ * browser's view transitions keep a picture of the page being left, and this
34679
+ * hands that picture to the finger:
34680
+ *
34681
+ * the first pixel of drag the URL changes (replaced, not pushed) and
34682
+ * the browser freezes the page being left
34683
+ * while the finger moves the two pictures are dragged, the old one a
34684
+ * still, the new one live under it
34685
+ * let go the movement plays out to the end…
34686
+ * let go too early …or backwards, and the URL is put back
34687
+ *
34688
+ * So the URL leads and the picture follows, which is the opposite of what one
34689
+ * would write by hand and the only order the router allows: nothing is ever
34690
+ * mounted that does not match. The page being pulled in is therefore mounting
34691
+ * WHILE it is being dragged in — it arrives as its own loading state and fills
34692
+ * in under the finger, which is honest about what is happening (it is being
34693
+ * fetched) and is the only thing that can happen without a second router.
34694
+ *
34695
+ * What travels is decided at the first pixel, like the axis: a travel brings in
34696
+ * ONE neighbour, and turning the hand around mid-drag puts the current page
34697
+ * back rather than fetching the other side. A hand that walks a whole page
34698
+ * across and keeps going is not turning around though — it is asking for the
34699
+ * next one, and the gesture relays into a second travel without being let go
34700
+ * of (see onEdge).
34701
+ *
34702
+ * Anything else that must follow the gesture — the trait under a tab bar, a
34703
+ * header — follows by being NAMED, not by being told: give it a
34704
+ * `view-transition-name` of its own and the browser animates it from where it
34705
+ * was to where it is, on the same clock as the pages. That is why the tab row
34706
+ * can stay where it is, outside this box, and still move with the finger.
34707
+ */
34708
+ const CAN_KEEP_PICTURE = Boolean(document.startViewTransition && !document.startViewTransition.isPolyfill);
34709
+ const startViewTransition = ensureDocumentStartViewTransition();
34710
+ const TRAVEL_ATTRIBUTE = "data-navi-route-travel";
34711
+ // While a finger holds the travel: the pictures stand still and go exactly
34712
+ // where it says (see the CSS, and scrubTravel).
34713
+ const HOLD_ATTRIBUTE = "data-navi-route-travel-held";
34714
+ // A travel a finger set off, for its whole life — including what plays out
34715
+ // after the finger is gone. It moves by another law than one asked for by a
34716
+ // press (see the CSS).
34717
+ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
34718
+ // A travel that changed its mind about where it was going (see
34719
+ // redirectTravel). Only the pages can be aimed somewhere else: everything the
34720
+ // transition carries was measured once, at the start, against a destination
34721
+ // this travel is no longer going to.
34722
+ const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
34723
+ const css$R = /* css */`
34724
+ .navi_route_travel {
34725
+ position: relative;
34726
+ /* Named, so the page inside this box is a picture of its own during a
34727
+ transition rather than part of the one big picture the document takes:
34728
+ the two pages can then move past each other while everything else stays
34729
+ where it is. */
34730
+ view-transition-name: navi-route-travel;
34731
+ /* The gesture takes the axis the pages travel on and leaves the other one
34732
+ to the page, so a list still scrolls under the same finger. */
34733
+ touch-action: pan-y;
34734
+ }
34735
+ .navi_route_travel[data-axis="y"] {
34736
+ touch-action: pan-x;
34737
+ }
34738
+
34739
+ /* Only while a travel of OURS is playing: everything below changes how the
34740
+ document animates, and the document belongs to the application the rest of
34741
+ the time. */
34742
+ :root[${TRAVEL_ATTRIBUTE}] {
34743
+ /* The page around the box is NOT taken as a picture, against the browser's
34744
+ own default: an element that has been captured is not painted where it
34745
+ was and cannot be pointed at either — every press lands on the document
34746
+ root instead. Capturing the whole page therefore freezes it in both
34747
+ senses at once, and a tab row beside a travel is dead for the length of
34748
+ every travel: nothing highlights, the cursor is an arrow, a press on the
34749
+ tab one changed one's mind about goes nowhere.
34750
+
34751
+ Left live, the page around answers as it always did, and nothing shows
34752
+ through where the pages are: the box IS captured (it is named below), so
34753
+ it paints nothing of its own, and the two pictures cover its rectangle
34754
+ between them at every moment of the travel. */
34755
+ view-transition-name: none;
34756
+
34757
+ /* The pictures are looked at, never touched: they are drawn in the top
34758
+ layer, above everything, so a hand reaching for a page that is still
34759
+ sliding would land on the picture of it and the box below would never
34760
+ hear the press. Nothing here is interactive — what the finger is
34761
+ reaching for is the travel underneath, and it must reach it. */
34762
+ &::view-transition,
34763
+ &::view-transition-group(*),
34764
+ &::view-transition-image-pair(*),
34765
+ &::view-transition-old(*),
34766
+ &::view-transition-new(*) {
34767
+ pointer-events: none;
34768
+ }
34769
+
34770
+ &::view-transition-old(navi-route-travel),
34771
+ &::view-transition-new(navi-route-travel) {
34772
+ height: 100%;
34773
+ /* The default cross-fade, dropped: two pages sliding past each other are
34774
+ two solid things, and seeing through one to the other says they are the
34775
+ same page changing its mind. */
34776
+ mix-blend-mode: normal;
34777
+ }
34778
+ /* The pages are cut at the edge of the box they travel in. Said HERE and
34779
+ nowhere else: these pictures are drawn in the top layer, so no overflow
34780
+ on any element of the document — not the box's own, not a frame around
34781
+ it — can reach them. Without it a page being pulled in is seen sliding
34782
+ across whatever sits beside the box. */
34783
+ &::view-transition-group(navi-route-travel),
34784
+ &::view-transition-image-pair(navi-route-travel) {
34785
+ overflow: clip;
34786
+ }
34787
+ &::view-transition-group(navi-route-travel) {
34788
+ animation-duration: var(--navi-route-travel-duration, 300ms);
34789
+ }
34790
+ }
34791
+
34792
+ /* Held by a finger: nothing moves on its own, and where the pictures stand is
34793
+ set by hand (scrubTravel). In CSS rather than paused in JS because JS
34794
+ cannot pause what does not exist yet: a transition is ready several frames
34795
+ after it is asked for — a navigation and a render later — and those frames
34796
+ are the beginning of the gesture. Played at their own pace, a quick swipe
34797
+ would be over before it was ever taken in hand, which is exactly what one
34798
+ sees: the page arriving lands at once instead of following the thumb. */
34799
+ :root[${HOLD_ATTRIBUTE}] {
34800
+ &::view-transition-group(*),
34801
+ &::view-transition-old(*),
34802
+ &::view-transition-new(*) {
34803
+ animation-play-state: paused;
34804
+ }
34805
+ }
34806
+
34807
+ /* Longhands, never the \`animation\` shorthand: the shorthand also writes
34808
+ animation-play-state, so it would set these back to running and undo the
34809
+ hold above — a finger would then watch the pages travel on their own. */
34810
+ :root[${TRAVEL_ATTRIBUTE}] {
34811
+ &::view-transition-old(navi-route-travel),
34812
+ &::view-transition-new(navi-route-travel) {
34813
+ animation-duration: var(--navi-route-travel-duration, 300ms);
34814
+ animation-timing-function: ease;
34815
+ animation-fill-mode: both;
34816
+ }
34817
+ }
34818
+
34819
+ /* A travel that turned around takes its pages with it and nothing else. Every
34820
+ other thing the transition carries — a bar under a tab row, a header — was
34821
+ PHOTOGRAPHED when the transition began: where it stood, and where it was
34822
+ going to stand. Both are fixed, and the second one is now a place nobody is
34823
+ going to. Worse, the thing itself has moved on in the live page (the bar is
34824
+ already under the tab one is heading for), so the picture and the thing are
34825
+ in two places at once — and two bars is what one sees.
34826
+
34827
+ So the pictures of everything that is not the pages are dropped, and those
34828
+ things are simply left where they are, live. A jump rather than a slide, on
34829
+ the one gesture that cannot have both. */
34830
+ :root[${TURNED_ATTRIBUTE}] {
34831
+ &::view-transition-group(*) {
34832
+ display: none;
34833
+ }
34834
+ /* …except the pages, which are what a travel is about. Listed after, so it
34835
+ wins on order rather than on a specificity war. */
34836
+ &::view-transition-group(navi-route-travel) {
34837
+ display: block;
34838
+ }
34839
+ }
34840
+
34841
+ /* Under a finger, the pace IS the finger: an eased travel would run ahead of
34842
+ it in the middle of the gesture and lag behind it at the ends, and what one
34843
+ feels then is the page leaving on its own rather than being pushed. The
34844
+ curve of the movement is the hand's, and it is already in the pull. Kept
34845
+ linear once it is let go of too: changing the curve of an animation that is
34846
+ halfway through moves the picture without anything having moved. */
34847
+ :root[${DRAGGED_ATTRIBUTE}] {
34848
+ &::view-transition-group(navi-route-travel),
34849
+ &::view-transition-old(navi-route-travel),
34850
+ &::view-transition-new(navi-route-travel) {
34851
+ animation-timing-function: linear;
34852
+ }
34853
+ }
34854
+ :root[${TRAVEL_ATTRIBUTE}="forward"] {
34855
+ &::view-transition-old(navi-route-travel) {
34856
+ animation-name: navi-route-travel-leave-towards-start;
34857
+ }
34858
+ &::view-transition-new(navi-route-travel) {
34859
+ animation-name: navi-route-travel-enter-from-end;
34860
+ }
34861
+ }
34862
+ :root[${TRAVEL_ATTRIBUTE}="back"] {
34863
+ &::view-transition-old(navi-route-travel) {
34864
+ animation-name: navi-route-travel-leave-towards-end;
34865
+ }
34866
+ &::view-transition-new(navi-route-travel) {
34867
+ animation-name: navi-route-travel-enter-from-start;
34868
+ }
34869
+ }
34870
+
34871
+ @keyframes navi-route-travel-leave-towards-start {
34872
+ from {
34873
+ translate: 0 0;
34874
+ }
34875
+ to {
34876
+ translate: -100% 0;
34877
+ }
34878
+ }
34879
+ @keyframes navi-route-travel-enter-from-end {
34880
+ from {
34881
+ translate: 100% 0;
34882
+ }
34883
+ to {
34884
+ translate: 0 0;
34885
+ }
34886
+ }
34887
+ @keyframes navi-route-travel-leave-towards-end {
34888
+ from {
34889
+ translate: 0 0;
34890
+ }
34891
+ to {
34892
+ translate: 100% 0;
34893
+ }
34894
+ }
34895
+ @keyframes navi-route-travel-enter-from-start {
34896
+ from {
34897
+ translate: -100% 0;
34898
+ }
34899
+ to {
34900
+ translate: 0 0;
34901
+ }
34902
+ }
34903
+ `;
34904
+
34905
+ /**
34906
+ * @type {import("ignore:preact").FunctionComponent<{
34907
+ * routes?: Array<object>,
34908
+ * axis?: "x"|"y",
34909
+ * travelByDrag?: boolean,
34910
+ * onTravel?: (detail: {route: object, cause: string}) => void|Promise<void>,
34911
+ * }>}
34912
+ * @param {Array<object>} [props.routes] - the tabs, in the order they are shown.
34913
+ * Read from the <Route> children by default, in the order they are written:
34914
+ * the router already holds that list, and asking a caller to write it twice is
34915
+ * asking for the two to disagree. Pass it to say another order, or when the
34916
+ * pages are not children of this box.
34917
+ * @param {"x"|"y"} [props.axis="x"] - which way the pages are laid out.
34918
+ * @param {boolean} [props.travelByDrag=true] - whether a pointer dragging the
34919
+ * page travels. Off where the gesture belongs to the content.
34920
+ * @param {(detail: {route: object, cause: "drag"|"wheel"|"revert"}) => void|Promise<void>} [props.onTravel]
34921
+ * - how to go to a route. The default REPLACES the current history entry
34922
+ * rather than pushing one: a swipe is how one browses a page, not a place one
34923
+ * aimed at, and three swipes back and forth must not bury the way out of the
34924
+ * page under six entries. A tab pressed is the other case and pushes, which
34925
+ * is what its <Link> already does.
34926
+ *
34927
+ * The pages are cut at the edge of this box while they travel, which is written
34928
+ * on the transition's own pseudo-elements — no overflow of the document reaches
34929
+ * pictures drawn in the top layer. It needs nothing of the browser beyond view
34930
+ * transitions themselves: a browser without them (Firefox) navigates without the
34931
+ * movement, and the gesture applies its change on release instead of dragging a
34932
+ * picture that does not exist.
34933
+ *
34934
+ * While a travel plays, the rest of the page is taken as a picture too — this
34935
+ * box asks for `view-transition-name: root` back for that time, so an
34936
+ * application that opts the document out for its own transitions gets its rule
34937
+ * back the moment the travel is over.
34938
+ */
34939
+ const RouteTravel = ({
34940
+ routes: routesProp,
34941
+ axis = "x",
34942
+ travelByDrag = true,
34943
+ onTravel = ({
34944
+ route
34945
+ }) => route.redirectTo(),
34946
+ className,
34947
+ children,
34948
+ ...rest
34949
+ }) => {
34950
+ import.meta.css = [css$R, "@jsenv/navi/src/nav/route_travel.jsx"];
34951
+ const elementRef = useRef();
34952
+ const gestureRef = useRef(null);
34953
+ // The travel in hand: the transition keeping the picture of the page being
34954
+ // left, the animations the finger drives, and what to do with them once the
34955
+ // browser has them ready. Null when no page is on its way anywhere.
34956
+ const travelRef = useRef(null);
34957
+ // The route this box has ASKED for and is still waiting to see arrive.
34958
+ // Routing is asynchronous: a travel's own navigation lands well after the
34959
+ // travel decided anything about it — sometimes after the travel was undone —
34960
+ // and read back as "the route changed" it would start a second travel nobody
34961
+ // asked for, over pictures that are already showing something else.
34962
+ const routeAskedForRef = useRef(null);
34963
+ // What a press stopped in flight, until the gesture says what it is about.
34964
+ const caughtAtPressRef = useRef(null);
34965
+ // The latest way to answer a gesture, for a watcher that outlives every
34966
+ // render (see the wheel effect below).
34967
+ const travelHandlersRef = useRef(null);
34968
+ const pointerDownRef = useRef(null);
34969
+ const routesFromChildren = useMemo(() => collectRoutes(children), [children]);
34970
+ const routes = routesProp || routesFromChildren;
34971
+
34972
+ // Which page is on screen, read from the routes themselves: every one of them
34973
+ // is read, so this re-renders when any of them starts or stops matching.
34974
+ let currentIndex = -1;
34975
+ for (let i = 0; i < routes.length; i++) {
34976
+ if (routes[i].matchingSignal.value) {
34977
+ currentIndex = i;
34978
+ }
34979
+ }
34980
+ // The page that was on screen when the change now happening was asked for:
34981
+ // a travel is between two of them, and by the time anything renders the first
34982
+ // one is already gone. Written after each render (below), so a subscriber
34983
+ // reading it — they all run before Preact flushes — reads the one being left.
34984
+ const currentIndexRef = useRef(currentIndex);
34985
+
34986
+ // One travel, whoever asked for it: a finger, a tab pressed, the browser's
34987
+ // own back button. What differs is only who moves it — the finger drives it
34988
+ // frame by frame (`scrub`), everything else lets it play.
34989
+ const beginTravel = ({
34990
+ route,
34991
+ fromRoute,
34992
+ direction,
34993
+ scrub,
34994
+ change
34995
+ }) => {
34996
+ const travel = {
34997
+ route,
34998
+ // The page this set off from, kept rather than looked up again: the URL
34999
+ // changes at the first pixel, so a moment later nothing on screen
35000
+ // remembers where it started.
35001
+ fromRoute,
35002
+ direction,
35003
+ scrub,
35004
+ ratio: 0,
35005
+ // The animations of the pictures, once the browser has them. Held still
35006
+ // by CSS until then, so an empty hand here costs nothing: there is
35007
+ // nothing to stop, only nowhere to put them yet.
35008
+ animations: null,
35009
+ ended: false
35010
+ };
35011
+ travelRef.current = travel;
35012
+ document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
35013
+ if (scrub) {
35014
+ holdPictures(travel);
35015
+ document.documentElement.setAttribute(DRAGGED_ATTRIBUTE, "");
35016
+ }
35017
+ routeAskedForRef.current = route;
35018
+ // The hold a navigation already took, if this travel is the answer to one:
35019
+ // taking another would be taking a hold on a page that is holding still.
35020
+ const releaseRendering = renderingHeldForRouting || holdRendering();
35021
+ renderingHeldForRouting = null;
35022
+ // The picture the browser is about to take must be of the page that was
35023
+ // asked for, and a route matching is not yet a page rendered.
35024
+ const viewTransition = startViewTransition(() => whileRouteRenders(route, async () => {
35025
+ releaseRendering();
35026
+ if (change) {
35027
+ await change();
35028
+ }
35029
+ }));
35030
+ travel.viewTransition = viewTransition;
35031
+ if (scrub) {
35032
+ // Said only now: the release has to have something to let go of, and the
35033
+ // transition did not exist a line above.
35034
+ travel.dropHold = holdViewTransition(() => {
35035
+ viewTransition.skipTransition();
35036
+ endTravel(travel);
35037
+ });
35038
+ }
35039
+ // Another transition starting SKIPS this one — there is only ever one in a
35040
+ // document. That must end the travel here and now, and above all lift the
35041
+ // hold: the hold is written in CSS against whatever transition is running
35042
+ // (it has to be, so that everything named — a trait under a tab row —
35043
+ // follows the same finger), so left on it would take hold of the transition
35044
+ // that has just replaced ours. Born paused, with nobody holding it, that
35045
+ // one never finishes: its pictures stand over a page that cannot be
35046
+ // touched anymore.
35047
+ viewTransition.finished.catch(() => {
35048
+ // A transition that fails before it ever calls back leaves the page held:
35049
+ // whoever asked for the hold gives it back, here as everywhere else.
35050
+ releaseRendering();
35051
+ endTravel(travel);
35052
+ });
35053
+ if (!scrub) {
35054
+ // Nobody is holding it: it plays as any transition does, and is over when
35055
+ // the browser says so.
35056
+ viewTransition.finished.then(() => {
35057
+ endTravel(travel);
35058
+ }, ignoreSkipped);
35059
+ return travel;
35060
+ }
35061
+ // The pictures exist from `ready` on, and the finger may have moved a long
35062
+ // way by then — held at their start meanwhile, so this is where they catch
35063
+ // up with it rather than where they set off.
35064
+ viewTransition.ready.then(() => {
35065
+ if (travel === travelRef.current) {
35066
+ scrubTravel(travel, travel.ratio);
35067
+ }
35068
+ }, ignoreSkipped);
35069
+ return travel;
35070
+ };
35071
+
35072
+ // A page change nobody here asked for: a tab pressed, a key, the back button.
35073
+ // The transition is started from the route's own announcement rather than
35074
+ // from a render, because a render is one flush too late — by then the DOM
35075
+ // holds the new page and the picture of the old one cannot be taken anymore.
35076
+ useLayoutEffect(() => {
35077
+ const unsubscribes = routes.map((route, index) => route.subscribeStatus(({
35078
+ matching
35079
+ }) => {
35080
+ if (!matching) {
35081
+ return;
35082
+ }
35083
+ // A page this box asked for itself — a travel's own navigation, or one
35084
+ // it had given up waiting on: what arrives here is the answer to a
35085
+ // question already answered, not somebody going somewhere.
35086
+ if (routeAskedForRef.current === route) {
35087
+ routeAskedForRef.current = null;
35088
+ currentIndexRef.current = index;
35089
+ return;
35090
+ }
35091
+ // Somewhere else arrived first: whatever this box was still waiting for
35092
+ // is not coming, or no longer means anything. Forgotten here rather
35093
+ // than kept, or the next press on that very tab would be taken for the
35094
+ // late answer to a question nobody remembers asking.
35095
+ routeAskedForRef.current = null;
35096
+ // Asked for a page while one was already on its way: the travel in
35097
+ // flight is the answer, aimed somewhere else. Starting a second one on
35098
+ // top would leave this one's pictures to be dropped mid-slide.
35099
+ if (travelRef.current) {
35100
+ currentIndexRef.current = index;
35101
+ retargetTravel(travelRef.current, route);
35102
+ return;
35103
+ }
35104
+ const fromIndex = currentIndexRef.current;
35105
+ currentIndexRef.current = index;
35106
+ if (fromIndex === -1 || fromIndex === index) {
35107
+ // Arriving from outside this row (or not moving at all): there is no
35108
+ // pair of pages to show, so there is nothing to travel between.
35109
+ return;
35110
+ }
35111
+ beginTravel({
35112
+ route,
35113
+ fromRoute: routes[fromIndex],
35114
+ direction: index > fromIndex ? "forward" : "back",
35115
+ scrub: false
35116
+ });
35117
+ }));
35118
+ return () => {
35119
+ for (const unsubscribe of unsubscribes) {
35120
+ unsubscribe();
35121
+ }
35122
+ };
35123
+ }, [routes]);
35124
+
35125
+ // Rendering is held for the length of a navigation, so that whatever picture
35126
+ // this box is about to take is of the page being LEFT (see holdRendering).
35127
+ // Held from before the navigation's first write, because by the time a route
35128
+ // announces that it matches, Preact has already been told and the render is
35129
+ // queued — a hold taken then is a hold taken too late.
35130
+ useLayoutEffect(() => {
35131
+ const stopWatchingStart = observeBeforeRouting(() => {
35132
+ renderingHeldForRouting = holdRendering();
35133
+ });
35134
+ // Nobody may have had a picture to take: this navigation is not always one
35135
+ // this box travels, and a page held for a change it does not animate is a
35136
+ // page that stutters for nothing. Whoever wanted it took it over
35137
+ // (beginTravel) while the change was being applied, and left nothing here.
35138
+ const stopWatchingEnd = observeAfterRouting(() => {
35139
+ const release = renderingHeldForRouting;
35140
+ renderingHeldForRouting = null;
35141
+ if (release) {
35142
+ release();
35143
+ }
35144
+ });
35145
+ return () => {
35146
+ stopWatchingStart();
35147
+ stopWatchingEnd();
35148
+ };
35149
+ }, []);
35150
+
35151
+ // What the next announcement will compare itself against: written after the
35152
+ // render that shows it, so it is always the page one is looking at.
35153
+ useLayoutEffect(() => {
35154
+ currentIndexRef.current = currentIndex;
35155
+ }, [currentIndex]);
35156
+
35157
+ // Let go of far enough: the movement carries on from under the finger, at its
35158
+ // own pace, to the end.
35159
+ const finishTravel = travel => {
35160
+ releaseHold();
35161
+ travel.viewTransition.finished.then(() => endTravel(travel), () => endTravel(travel));
35162
+ };
35163
+
35164
+ // Let go of too early: the pages go back the way they came, and the URL with
35165
+ // them. The way back is not a travel of its own — the same animations are run
35166
+ // backwards, and the page that was left is put back under the picture BEFORE
35167
+ // the picture is dropped, so the two are the same thing at the moment they
35168
+ // are swapped and nothing is seen changing.
35169
+ const revertTravel = travel => {
35170
+ travel.reverting = true;
35171
+ // A revert somebody ASKED for — a press on the tab this travel came from —
35172
+ // has a problem the others do not: that page is already back, and the
35173
+ // picture being brought in is LIVE, so it shows the page one is going back
35174
+ // to. Both sides then show the same thing and the way back is invisible:
35175
+ // one presses, and one is simply there.
35176
+ //
35177
+ // So the pages are held where they are until the pictures have finished
35178
+ // going back. Only the pages: unlike the hold a navigation takes to have
35179
+ // its picture taken, nothing is being photographed here — everything else
35180
+ // may go on rendering, and a tab row beside the box keeps answering.
35181
+ const releaseRendering = freezeRouteRender();
35182
+ const animations = travelAnimations(travel);
35183
+ // The way back is paid for in DISTANCE, not in time. The way in is eased:
35184
+ // at half of its TIME the pictures have covered ~80% of their distance, so
35185
+ // a travel caught "half-way" by the eye has barely begun by the clock.
35186
+ // Rewound at -1 it plays those few milliseconds back through the steep end
35187
+ // of the curve — nearly the whole visible distance collapses into two
35188
+ // frames, and what one sees is a snap, not a return. So the pictures are
35189
+ // walked home over `how far they LOOK from home`, at the travel's own
35190
+ // pace, each animation at the rate that gets it there in that time.
35191
+ const wallTime = revertWalkTime(animations);
35192
+ for (const animation of animations) {
35193
+ const timeLeft = animation.currentTime;
35194
+ const rate = wallTime > 17 && timeLeft > 0 ? -(timeLeft / wallTime) : -1;
35195
+ // updatePlaybackRate, never the playbackRate setter: these animations
35196
+ // run on the compositor, and the setter is a non-seamless change there —
35197
+ // on screen the pictures jump straight to their end state while the
35198
+ // Animation object ticks backwards unseen. What one sees then is the
35199
+ // travel SNAPPING home instead of returning, and no reading of
35200
+ // getAnimations() will say so: only the compositor knows, and
35201
+ // updatePlaybackRate is how a new rate is handed to it in flight.
35202
+ animation.updatePlaybackRate(rate);
35203
+ }
35204
+ releaseHold(travel);
35205
+ const backAtTheStart = animations.length ? Promise.all(animations.map(animation => animation.finished.catch(() => {}))) :
35206
+ // Nothing to run backwards — a transition the browser skipped, or one
35207
+ // that never became ready. There is no picture to undo either, so the
35208
+ // way back is the state alone.
35209
+ Promise.resolve();
35210
+ backAtTheStart.then(async () => {
35211
+ try {
35212
+ routeAskedForRef.current = travel.fromRoute;
35213
+ // The page that was left is put back UNDER the picture before the
35214
+ // picture is dropped, so the two are the same thing at the moment they
35215
+ // are swapped: that only holds once the page is really back.
35216
+ if (travel.fromRoute.matchingSignal.peek()) {
35217
+ // It never left: the press that set this revert off put it back
35218
+ // there, and the pages have been held where they were until now.
35219
+ // Nothing to ask for, and nothing to wait for — waiting anyway is a
35220
+ // render that never comes and a page frozen under its own pictures.
35221
+ releaseRendering();
35222
+ } else {
35223
+ await whileRouteRenders(travel.fromRoute, () => onTravel({
35224
+ route: travel.fromRoute,
35225
+ cause: "revert"
35226
+ }));
35227
+ }
35228
+ travel.viewTransition.skipTransition();
35229
+ } finally {
35230
+ releaseRendering();
35231
+ // A travel ENDS, whatever happened on the way back: put the state back,
35232
+ // fail to drop the picture, be interrupted by something else — the one
35233
+ // thing that must not happen is a travel that stays "in flight"
35234
+ // forever. Nothing would lift the hold, the pictures would stand where
35235
+ // they are over a page that cannot be touched, and every gesture after
35236
+ // this one would find the box busy.
35237
+ endTravel(travel);
35238
+ }
35239
+ });
35240
+ };
35241
+
35242
+ // Taken back in hand: the pictures stop where they are and answer the finger
35243
+ // again (see the CSS hold).
35244
+ const holdTravel = travel => {
35245
+ if (travel.ended || travel.reverting) {
35246
+ return;
35247
+ }
35248
+ travel.scrub = true;
35249
+ holdPictures(travel);
35250
+ // Whatever asks for a transition next takes ours away (there is one per
35251
+ // document): it must find this one already let go of, or it inherits a hold
35252
+ // nobody is holding.
35253
+ travel.dropHold = holdViewTransition(() => {
35254
+ travel.viewTransition?.skipTransition();
35255
+ endTravel(travel);
35256
+ });
35257
+ };
35258
+
35259
+ // The same travel, aimed at another page. The still it starts from does not
35260
+ // change — only what is being brought in against it, and that one is LIVE:
35261
+ // pointing the router elsewhere is all it takes for the picture to show that
35262
+ // page instead.
35263
+ const redirectTravel = (travel, route, direction) => {
35264
+ travel.route = route;
35265
+ // Everything the transition carries that is NOT the pages was measured
35266
+ // against a destination this travel is no longer going to (see the CSS).
35267
+ document.documentElement.setAttribute(TURNED_ATTRIBUTE, "");
35268
+ if (direction === travel.direction) {
35269
+ // Same way, another page: the pictures in hand are already the right
35270
+ // pair, and nothing has to move.
35271
+ return;
35272
+ }
35273
+ travel.direction = direction;
35274
+ travel.ratio = 0;
35275
+ // The other way round is another pair of keyframes, and naming another
35276
+ // animation builds another Animation: whatever was collected answers to
35277
+ // nobody now. They start again from the beginning, which is where a travel
35278
+ // that turns around is.
35279
+ travel.animations = null;
35280
+ document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
35281
+ };
35282
+
35283
+ // Somebody asked for a page while one was on its way. Where they asked for
35284
+ // decides what that means.
35285
+ const retargetTravel = (travel, route) => {
35286
+ if (travel.scrub || travel.reverting || travel.ended || travel.noPicture) {
35287
+ // A hand is holding the pages, or they are already on their way back:
35288
+ // either way this travel's end is decided by somebody else.
35289
+ return;
35290
+ }
35291
+ if (route === travel.route) {
35292
+ // Already on its way there.
35293
+ return;
35294
+ }
35295
+ if (route === travel.fromRoute) {
35296
+ // Back where it set off from: that is not another travel, it is this one
35297
+ // undone — the same pictures, run backwards.
35298
+ revertTravel(travel);
35299
+ return;
35300
+ }
35301
+ const fromIndex = routes.indexOf(travel.fromRoute);
35302
+ const toIndex = routes.indexOf(route);
35303
+ if (fromIndex === -1 || toIndex === -1) {
35304
+ return;
35305
+ }
35306
+ redirectTravel(travel, route, toIndex > fromIndex ? "forward" : "back");
35307
+ };
35308
+ const endTravel = travel => {
35309
+ if (travel.ended) {
35310
+ return;
35311
+ }
35312
+ travel.ended = true;
35313
+ travel.dropHold?.();
35314
+ travel.dropHold = null;
35315
+ // Its own hold, always — whether or not this travel is still the current
35316
+ // one. Nobody else will lift it.
35317
+ releaseHold(travel);
35318
+ if (travelRef.current === travel) {
35319
+ travelRef.current = null;
35320
+ document.documentElement.removeAttribute(TRAVEL_ATTRIBUTE);
35321
+ document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
35322
+ document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
35323
+ }
35324
+ };
35325
+
35326
+ // What a gesture is about, whichever hand made it: a thumb dragging the page
35327
+ // and two fingers pushing it sideways on a trackpad ask for the same travel,
35328
+ // so they are answered by the same three callbacks and only the reading of
35329
+ // the input differs (see drag_to_travel.js).
35330
+ const boxSizeOnAxis = () => {
35331
+ const box = elementRef.current.getBoundingClientRect();
35332
+ return axis === "x" ? box.width : box.height;
35333
+ };
35334
+ const travelHandlers = {
35335
+ onStart: ({
35336
+ sign,
35337
+ target
35338
+ }) => {
35339
+ const size = boxSizeOnAxis();
35340
+ const travelInFlight = travelRef.current;
35341
+ if (travelInFlight) {
35342
+ // A travel is already playing, and a second one cannot be started on
35343
+ // top of it: there is one picture of the page being left, and it is
35344
+ // taken. So the gesture takes over THIS travel instead of asking for
35345
+ // another — which is what a hand reaching for a page still sliding is
35346
+ // asking for anyway. It is refused only when there is nothing to take
35347
+ // over (no picture at all).
35348
+ //
35349
+ // Never given up, even then: a gesture handed back to the browser is a
35350
+ // page that rocks under a travel that is already moving.
35351
+ // A travel being undone is not up for grabs either: it is already on
35352
+ // its way back and its end is decided. Held again mid-revert, its
35353
+ // animations would never finish — and the wait for them never resolves,
35354
+ // so the pictures stay where they are, over a page that cannot be
35355
+ // touched anymore.
35356
+ if (travelInFlight.noPicture || travelInFlight.ended || travelInFlight.reverting) {
35357
+ return {
35358
+ size,
35359
+ travelBack: false,
35360
+ travelOn: false
35361
+ };
35362
+ }
35363
+ caughtAtPressRef.current = null;
35364
+ holdTravel(travelInFlight);
35365
+ // Where the pictures stand right now, said as a pull: what the finger
35366
+ // continues from, so nothing jumps when it takes them over.
35367
+ const ratio = ratioOfTravel(travelInFlight);
35368
+ travelInFlight.ratio = ratio;
35369
+ const pulledSign = travelInFlight.direction === "back" ? 1 : -1;
35370
+ return {
35371
+ size,
35372
+ slack: ratio * size * pulledSign,
35373
+ // One box, the one being travelled. Either of its ends can be walked
35374
+ // out of and the hand carries on into the next page, but that is a
35375
+ // travel of its own and the gesture asks for it when it gets there
35376
+ // (see onEdge) — from here there is one page on its way.
35377
+ travelBack: travelInFlight.direction === "back",
35378
+ travelOn: travelInFlight.direction === "forward"
35379
+ };
35380
+ }
35381
+ // Dragging the page towards the end of the axis brings in what is
35382
+ // BEFORE it, the way pushing a sheet to the right reveals its left.
35383
+ const route = sign > 0 ? routes[currentIndex - 1] : routes[currentIndex + 1];
35384
+ if (!route || !size || scrollRoomTowards(target, elementRef.current, axis, sign)) {
35385
+ return false;
35386
+ }
35387
+ if (CAN_KEEP_PICTURE) {
35388
+ beginTravel({
35389
+ route,
35390
+ fromRoute: routes[currentIndex],
35391
+ direction: sign > 0 ? "back" : "forward",
35392
+ scrub: true,
35393
+ change: () => onTravel({
35394
+ route,
35395
+ cause: "drag"
35396
+ })
35397
+ });
35398
+ } else {
35399
+ travelRef.current = {
35400
+ route,
35401
+ noPicture: true,
35402
+ ended: false
35403
+ };
35404
+ }
35405
+ return {
35406
+ size,
35407
+ travelBack: sign > 0,
35408
+ travelOn: sign < 0
35409
+ };
35410
+ },
35411
+ onPull: ({
35412
+ progress
35413
+ }) => {
35414
+ const travel = travelRef.current;
35415
+ if (!travel || travel.noPicture) {
35416
+ return;
35417
+ }
35418
+ // Only what goes the way the gesture set off: a hand turning around
35419
+ // mid-drag is putting the page back, and there is no second picture to
35420
+ // show it anything else.
35421
+ const ratio = travel.direction === "back" ? progress : -progress;
35422
+ travel.ratio = ratio > 0 ? ratio : 0;
35423
+ scrubTravel(travel, travel.ratio);
35424
+ },
35425
+ // A page walked all the way to one of its ends and the finger still going:
35426
+ // what it asks for is the page past that end, and it has said so by not
35427
+ // stopping. Which end decides how it is answered, and the two are not the
35428
+ // same amount of work.
35429
+ onEdge: ({
35430
+ sign
35431
+ }) => {
35432
+ const travel = travelRef.current;
35433
+ if (!travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
35434
+ return false;
35435
+ }
35436
+ // Where the page in hand is coming from, said the way the gesture says
35437
+ // it: dragging towards the end of the axis brings in what comes BEFORE.
35438
+ const pulledSign = travel.direction === "back" ? 1 : -1;
35439
+ const direction = sign > 0 ? "back" : "forward";
35440
+ if (sign === pulledSign) {
35441
+ // Walked whole. What the pictures show is the page that has arrived,
35442
+ // and the one leaving is gone: there is no pair left to travel with, so
35443
+ // the next travel needs pictures of its own — a navigation, a render, a
35444
+ // snapshot, and for those few frames the page does not follow the
35445
+ // finger before catching up with it (see `ready` in beginTravel). At
35446
+ // the start of a gesture that gap is invisible, the hand has barely
35447
+ // moved; here the hand is at full speed, and this is what it costs.
35448
+ //
35449
+ // Where we are is what THIS travel was bringing in — the URL changed at
35450
+ // the first pixel, so `currentIndex` belongs to a render this gesture
35451
+ // is older than.
35452
+ const fromIndex = routes.indexOf(travel.route);
35453
+ const route = direction === "back" ? routes[fromIndex - 1] : routes[fromIndex + 1];
35454
+ if (fromIndex === -1 || !route) {
35455
+ return false;
35456
+ }
35457
+ // At their very end before they are let go of: what ends the travel in
35458
+ // hand is the next transition starting (there is one per document, and
35459
+ // the funnel skips ours), and a picture skipped short of its end is a
35460
+ // page seen jumping the last few pixels.
35461
+ scrubTravel(travel, 1);
35462
+ travel.ratio = 1;
35463
+ beginTravel({
35464
+ route,
35465
+ fromRoute: travel.route,
35466
+ direction,
35467
+ scrub: true,
35468
+ change: () => onTravel({
35469
+ route,
35470
+ cause: "drag"
35471
+ })
35472
+ });
35473
+ return {
35474
+ size: boxSizeOnAxis(),
35475
+ travelBack: sign > 0,
35476
+ travelOn: sign < 0
35477
+ };
35478
+ }
35479
+ // Walked back to where it began, and out the other side. Nothing has to
35480
+ // be built here: the pictures in hand are ALREADY the pair this new
35481
+ // travel needs — the still of the page it starts from is the same one,
35482
+ // and the picture being brought in is live, so pointing the router at the
35483
+ // other neighbour is enough for it to show that one instead. The travel
35484
+ // turns around where it stands, on the same transition and under the same
35485
+ // hand, and there is no gap at all.
35486
+ const fromIndex = routes.indexOf(travel.fromRoute);
35487
+ const route = direction === "back" ? routes[fromIndex - 1] : routes[fromIndex + 1];
35488
+ if (fromIndex === -1 || !route) {
35489
+ return false;
35490
+ }
35491
+ redirectTravel(travel, route, direction);
35492
+ routeAskedForRef.current = route;
35493
+ onTravel({
35494
+ route,
35495
+ cause: "drag"
35496
+ });
35497
+ return {
35498
+ size: boxSizeOnAxis(),
35499
+ travelBack: sign > 0,
35500
+ travelOn: sign < 0
35501
+ };
35502
+ },
35503
+ onEnd: ({
35504
+ travels
35505
+ }) => {
35506
+ gestureRef.current = null;
35507
+ caughtAtPressRef.current = null;
35508
+ const travel = travelRef.current;
35509
+ if (!travel) {
35510
+ // The travel this gesture was holding ended under it. Nothing left to
35511
+ // decide, but the hold is this gesture's own doing and nobody else will
35512
+ // take it off — a hold nobody lifts is a page nobody can touch.
35513
+ releaseHold();
35514
+ return;
35515
+ }
35516
+ if (travel.noPicture) {
35517
+ travelRef.current = null;
35518
+ if (travels) {
35519
+ onTravel({
35520
+ route: travel.route,
35521
+ cause: "drag"
35522
+ });
35523
+ }
35524
+ return;
35525
+ }
35526
+ if (travels) {
35527
+ finishTravel(travel);
35528
+ return;
35529
+ }
35530
+ revertTravel(travel);
35531
+ }
35532
+ };
35533
+ const onPointerDown = pointerDownEvent => {
35534
+ if (!travelByDrag || gestureRef.current) {
35535
+ return;
35536
+ }
35537
+ // Touching something that is moving STOPS it, right there, before the
35538
+ // gesture has said anything about itself. Waiting for the first pixels that
35539
+ // decide an axis would let the pages travel on under a finger that has
35540
+ // already landed on them, which is the one moment a hand expects to be
35541
+ // obeyed without asking. If the press turns out to be nothing, the travel
35542
+ // is let go of again and carries on (see onGiveUp).
35543
+ const travelToCatch = travelRef.current;
35544
+ if (travelToCatch && !travelToCatch.noPicture && !travelToCatch.ended && !travelToCatch.reverting) {
35545
+ holdTravel(travelToCatch);
35546
+ caughtAtPressRef.current = travelToCatch;
35547
+ }
35548
+ // A travel already playing is not a reason to refuse the press: a hand
35549
+ // reaching for a page that is still sliding is reaching for THAT page, and
35550
+ // the gesture takes it over (see onStart).
35551
+ if (currentIndex === -1) {
35552
+ return;
35553
+ }
35554
+ const gesture = startDragToTravel(pointerDownEvent, {
35555
+ element: elementRef.current,
35556
+ axes: axis,
35557
+ // Caught in flight: the hand is already in the gesture (see above), so it
35558
+ // is answered from its first pixel, on the axis the pages travel.
35559
+ immediate: caughtAtPressRef.current ? axis : false,
35560
+ ...travelHandlers,
35561
+ onGiveUp: () => {
35562
+ gestureRef.current = null;
35563
+ // A press that never became a gesture: whatever it stopped goes on its
35564
+ // way, from where the finger caught it.
35565
+ const caught = caughtAtPressRef.current;
35566
+ caughtAtPressRef.current = null;
35567
+ if (caught && !caught.ended) {
35568
+ releaseHold(caught);
35569
+ }
35570
+ }
35571
+ });
35572
+ gestureRef.current = gesture;
35573
+ };
35574
+
35575
+ // A press that lands ON the box while a travel is playing does not reach it:
35576
+ // the browser's transition covers the page and the press is delivered to the
35577
+ // document root instead, whatever the pictures are told about pointer events.
35578
+ // So while a travel plays, the press is caught at the document and handed to
35579
+ // the box when it fell inside it — which is where the hand thinks it pressed.
35580
+ pointerDownRef.current = onPointerDown;
35581
+ useLayoutEffect(() => {
35582
+ const onDocumentPointerDown = pointerDownEvent => {
35583
+ if (!travelRef.current) {
35584
+ return;
35585
+ }
35586
+ const boxElement = elementRef.current;
35587
+ if (!boxElement || boxElement.contains(pointerDownEvent.target)) {
35588
+ // It got there on its own.
35589
+ return;
35590
+ }
35591
+ const {
35592
+ left,
35593
+ right,
35594
+ top,
35595
+ bottom
35596
+ } = boxElement.getBoundingClientRect();
35597
+ const {
35598
+ clientX,
35599
+ clientY
35600
+ } = pointerDownEvent;
35601
+ if (clientX < left || clientX > right || clientY < top || clientY > bottom) {
35602
+ return;
35603
+ }
35604
+ pointerDownRef.current(pointerDownEvent);
35605
+ };
35606
+ document.addEventListener("pointerdown", onDocumentPointerDown, true);
35607
+ return () => {
35608
+ document.removeEventListener("pointerdown", onDocumentPointerDown, true);
35609
+ };
35610
+ }, []);
35611
+
35612
+ // A wheel pushing the box sideways asks for a PAGE, not for a place between
35613
+ // two: one push, one neighbour — the same thing a tab pressed asks for, and
35614
+ // it plays at its own pace rather than under a hand (see watchWheelTravel).
35615
+ const travelOneStep = sign => {
35616
+ const travelInFlight = travelRef.current;
35617
+ if (travelInFlight?.scrub) {
35618
+ // A hand is holding the pages. They are its until it lets go.
35619
+ return;
35620
+ }
35621
+ // Where the box is going, which is not where it is: a step asked for while
35622
+ // a travel plays is the page after the one on its way.
35623
+ const fromRoute = travelInFlight ? travelInFlight.route : routes[currentIndex];
35624
+ const fromIndex = routes.indexOf(fromRoute);
35625
+ if (fromIndex === -1) {
35626
+ return;
35627
+ }
35628
+ const route = sign > 0 ? routes[fromIndex - 1] : routes[fromIndex + 1];
35629
+ if (!route) {
35630
+ return;
35631
+ }
35632
+ if (travelInFlight) {
35633
+ // Finished where it stands before the next one sets off: what ends it is
35634
+ // that transition starting, and a picture dropped short of its end is a
35635
+ // page seen jumping the last few pixels.
35636
+ scrubTravel(travelInFlight, 1);
35637
+ travelInFlight.ratio = 1;
35638
+ }
35639
+ beginTravel({
35640
+ route,
35641
+ fromRoute,
35642
+ direction: sign > 0 ? "back" : "forward",
35643
+ scrub: false,
35644
+ change: () => onTravel({
35645
+ route,
35646
+ cause: "wheel"
35647
+ })
35648
+ });
35649
+ };
35650
+
35651
+ // Reached through a ref, and the watcher is never rebuilt for it: a travel
35652
+ // CHANGES the current page, so anything listening on `currentIndex` would be
35653
+ // torn down halfway through the very gesture that is moving it.
35654
+ travelHandlersRef.current = travelHandlers;
35655
+ const travelOneStepRef = useRef(null);
35656
+ travelOneStepRef.current = travelOneStep;
35657
+ useLayoutEffect(() => {
35658
+ if (!travelByDrag) {
35659
+ return undefined;
35660
+ }
35661
+ return watchWheelTravel(elementRef.current, {
35662
+ axes: axis,
35663
+ onStep: ({
35664
+ sign
35665
+ }) => travelOneStepRef.current(sign)
35666
+ });
35667
+ }, [travelByDrag, axis]);
35668
+ return jsx("div", {
35669
+ ...rest,
35670
+ ref: elementRef,
35671
+ className: className ? `navi_route_travel ${className}` : "navi_route_travel",
35672
+ "data-axis": axis
35673
+ // What travels here, and on which axis: read by the shared gesture
35674
+ // stylesheet, which keeps this box's scrolling from spilling onto the
35675
+ // page (see drag_to_travel.js).
35676
+ ,
35677
+
35678
+ "data-drag-travel": travelByDrag ? axis : undefined,
35679
+ onPointerDown: onPointerDown,
35680
+ children: children
35681
+ });
35682
+ };
35683
+
35684
+ // Nobody holds the pictures anymore: whatever they were told (a time to stand
35685
+ // at, a direction to run in) is what they carry on from.
35686
+ // Which travel is keeping the pictures still, if any. The hold belongs to the
35687
+ // travel that took it and only that one may give it back: a travel ending after
35688
+ // another has taken over must not lift a hold it no longer owns, and — the way
35689
+ // this went wrong — a travel whose end comes once something else has replaced
35690
+ // it must still lift its OWN. A hold left behind is a page frozen under
35691
+ // pictures nobody is holding.
35692
+ let travelHoldingPictures = null;
35693
+ const holdPictures = travel => {
35694
+ travelHoldingPictures = travel;
35695
+ document.documentElement.setAttribute(HOLD_ATTRIBUTE, "");
35696
+ };
35697
+ const releaseHold = travel => {
35698
+ if (travel && travelHoldingPictures !== travel) {
35699
+ return;
35700
+ }
35701
+ travelHoldingPictures = null;
35702
+ document.documentElement.removeAttribute(HOLD_ATTRIBUTE);
35703
+ };
35704
+
35705
+ // The browser does not take the picture of the page being left when a
35706
+ // transition is ASKED for — it takes it at the next frame, just before running
35707
+ // the update callback. Preact renders sooner than that, in a microtask: so a
35708
+ // change nobody here asked for (a tab pressed, the back button) has already
35709
+ // reached the DOM when the picture is taken, and the picture is of the page
35710
+ // ARRIVING. Both sides of the travel then show it, and one watches a page slide
35711
+ // onto itself.
35712
+ //
35713
+ // So what Preact has queued waits until the update callback, which is the
35714
+ // moment the API is built around — the change belongs inside it. The whole
35715
+ // document is held, for the one frame the browser needs: it is about to be
35716
+ // frozen under a picture anyway.
35717
+ let renderingHold = null;
35718
+ // The hold a navigation took on its way in, until a travel takes it over or the
35719
+ // navigation turns out to be one nobody here animates.
35720
+ let renderingHeldForRouting = null;
35721
+ const holdRendering = () => {
35722
+ if (renderingHold) {
35723
+ return renderingHold.release;
35724
+ }
35725
+ const debounceRenderingBefore = options.debounceRendering;
35726
+ const hold = {
35727
+ render: null,
35728
+ release: () => {
35729
+ // Only the hold that is still standing may be given back: a travel
35730
+ // ending after another has taken over must not let go of what it does
35731
+ // not hold.
35732
+ if (renderingHold !== hold) {
35733
+ return;
35734
+ }
35735
+ renderingHold = null;
35736
+ options.debounceRendering = debounceRenderingBefore;
35737
+ const {
35738
+ render
35739
+ } = hold;
35740
+ hold.render = null;
35741
+ if (render) {
35742
+ render();
35743
+ }
35744
+ }
35745
+ };
35746
+ renderingHold = hold;
35747
+ options.debounceRendering = render => {
35748
+ hold.render = render;
35749
+ };
35750
+ return hold.release;
35751
+ };
35752
+
35753
+ // The animations of the pictures, asked for again until there are some: they
35754
+ // come into existence with the transition, several frames after it was asked
35755
+ // for, and the gesture has already begun by then. Kept once found — the set
35756
+ // does not change for the length of one travel.
35757
+ const travelAnimations = travel => {
35758
+ if (travel.animations) {
35759
+ return travel.animations;
35760
+ }
35761
+ const animations = [];
35762
+ for (const animation of document.getAnimations()) {
35763
+ const pseudoElement = animation.effect?.pseudoElement;
35764
+ if (pseudoElement && pseudoElement.startsWith("::view-transition")) {
35765
+ animations.push(animation);
35766
+ }
35767
+ }
35768
+ if (animations.length) {
35769
+ travel.animations = animations;
35770
+ }
35771
+ return animations;
35772
+ };
35773
+
35774
+ // How far a travel has come, read off the pictures themselves rather than off
35775
+ // what the last gesture wrote: one let go of is still moving, and a hand
35776
+ // reaching for it must find it where it IS.
35777
+ const ratioOfTravel = travel => {
35778
+ const animations = travelAnimations(travel);
35779
+ // The pages' own animation, not the first that comes: everything the travel
35780
+ // carries along is animated too (a trait under a tab row, the page behind),
35781
+ // each with a duration of its own. A time read on one of those and turned
35782
+ // into a fraction of ANOTHER lands anywhere — over 1 more often than not,
35783
+ // which reads as a travel already over and jumps the pictures to their end.
35784
+ const animation = animations.find(candidate => candidate.effect?.pseudoElement?.includes("navi-route-travel")) || animations[0];
35785
+ if (!animation) {
35786
+ return travel.ratio;
35787
+ }
35788
+ const timing = animation.effect.getComputedTiming();
35789
+ const duration = timing.delay + timing.activeDuration;
35790
+ if (!duration) {
35791
+ return travel.ratio;
35792
+ }
35793
+ return animation.currentTime / duration;
35794
+ };
35795
+
35796
+ // CSS `ease`, evaluated: time in, distance out. Solved numerically because
35797
+ // the curve is parametric — two cubics sharing a parameter, with no closed
35798
+ // form for one against the other. Twenty halvings put the answer well under
35799
+ // a pixel of a screen-wide travel.
35800
+ const CSS_EASE = [0.25, 0.1, 0.25, 1];
35801
+ const bezierAxis = (s, a, b) => 3 * (1 - s) * (1 - s) * s * a + 3 * (1 - s) * s * s * b + s * s * s;
35802
+ const easedProgress = (x, [x1, y1, x2, y2]) => {
35803
+ let low = 0;
35804
+ let high = 1;
35805
+ for (let i = 0; i < 20; i++) {
35806
+ const mid = (low + high) / 2;
35807
+ if (bezierAxis(mid, x1, x2) < x) {
35808
+ low = mid;
35809
+ } else {
35810
+ high = mid;
35811
+ }
35812
+ }
35813
+ return bezierAxis((low + high) / 2, y1, y2);
35814
+ };
35815
+
35816
+ // How long the way back should take: the distance the pictures visibly are
35817
+ // from home, converted to time at the travel's own pace. The distance is
35818
+ // computed from the clock THROUGH the easing curve, never read off the
35819
+ // pseudo-elements: getComputedStyle on them answers with the un-animated
35820
+ // value — the animated one lives on the compositor, where no reading from
35821
+ // here reaches (the same trap as the playbackRate setter above).
35822
+ const revertWalkTime = animations => {
35823
+ const animation = animations.find(candidate => candidate.effect?.pseudoElement?.includes("navi-route-travel"));
35824
+ if (!animation) {
35825
+ return 0;
35826
+ }
35827
+ const timing = animation.effect.getComputedTiming();
35828
+ const duration = timing.delay + timing.activeDuration;
35829
+ if (!duration) {
35830
+ return 0;
35831
+ }
35832
+ const temporal = animation.currentTime / duration;
35833
+ // The easing sits on the keyframes, where a CSS animation's
35834
+ // animation-timing-function ends up. "ease" is what our travels play;
35835
+ // anything else (linear under a finger, see the DRAGGED attribute) maps
35836
+ // time to distance one for one.
35837
+ const easing = animation.effect.getKeyframes()[0]?.easing;
35838
+ const visibleRatio = easing === "ease" ? easedProgress(temporal, CSS_EASE) : temporal;
35839
+ return visibleRatio * duration;
35840
+ };
35841
+
35842
+ // Where the two pictures stand, said as a moment in the movement they would
35843
+ // have played on their own: the browser knows how they move (it is written in
35844
+ // CSS), so the finger only has to say how far in. They are held still by CSS
35845
+ // while this lasts, so a time written here is a place they stay at.
35846
+ const scrubTravel = (travel, ratio) => {
35847
+ for (const animation of travelAnimations(travel)) {
35848
+ const timing = animation.effect.getComputedTiming();
35849
+ const duration = timing.delay + timing.activeDuration;
35850
+ animation.currentTime = ratio * duration;
35851
+ }
35852
+ };
35853
+
35854
+ // A route change, carried out and then waited for until the page it selects is
35855
+ // really on screen. The container doing the swapping is the only one who knows
35856
+ // when that is (observeRouteRender): a route matching is a signal changing, and
35857
+ // how many passes Preact takes to answer it is its own business.
35858
+ //
35859
+ // Nothing is waited for when the change did not take — a route refused, a
35860
+ // redirect somewhere else. There is no page on its way then, and this runs
35861
+ // inside the callback of a view transition: the browser has stopped rendering
35862
+ // and is waiting on this very promise to take its picture, so a wait that never
35863
+ // ends is a page frozen under a transition that never became ready.
35864
+ const whileRouteRenders = async (route, change) => {
35865
+ let stopListening;
35866
+ const rendered = new Promise(resolve => {
35867
+ // Listened for before the change, or a render landing while the change is
35868
+ // being awaited is a render nobody heard.
35869
+ stopListening = observeRouteRender(resolve);
35870
+ });
35871
+ try {
35872
+ await change();
35873
+ if (route.matchingSignal.peek()) {
35874
+ await rendered;
35875
+ }
35876
+ } finally {
35877
+ stopListening();
35878
+ }
35879
+ };
35880
+
35881
+ // A transition skipped by another one starting is an outcome, not a failure.
35882
+ const ignoreSkipped = () => {};
35883
+
34379
35884
  const routeAction = (
34380
35885
  routeOrRoutes,
34381
35886
  action,
@@ -36336,6 +37841,13 @@ const useIsVisited = (url) => {
36336
37841
  */
36337
37842
  const BinderItemContext = createContext(null);
36338
37843
 
37844
+ /**
37845
+ * What a <Link> learns from the <Nav> around it: where to draw the bar that
37846
+ * says "you are here", and the name under which the browser is to recognise
37847
+ * that bar from one page to the next (see nav.jsx).
37848
+ */
37849
+ const NavContext = createContext(null);
37850
+
36339
37851
  /*
36340
37852
  * Custom hook to apply semi-transparent color when an element should be dimmed.
36341
37853
  *
@@ -36634,15 +38146,15 @@ installImportMetaCssBuild(import.meta);const css$O = /* css */`
36634
38146
  margin-left: -0.1em;
36635
38147
  }
36636
38148
 
36637
- &[data-appearance="text"] {
38149
+ &[data-variant="text"] {
36638
38150
  --link-color: unset;
36639
38151
  --link-text-decoration: none;
36640
38152
  }
36641
- &[data-appearance="icon"] {
38153
+ &[data-variant="icon"] {
36642
38154
  --link-color: unset;
36643
38155
  --link-text-decoration: none;
36644
38156
  }
36645
- &[data-appearance="tab"] {
38157
+ &[data-variant="tab"] {
36646
38158
  --link-background-hover: color-mix(
36647
38159
  in srgb,
36648
38160
  var(--link-background, transparent),
@@ -36779,15 +38291,15 @@ Object.assign(PSEUDO_CLASSES, {
36779
38291
  * (`navi_value`); defaults to `href`.
36780
38292
  * @param {boolean} [props.current] - Forces the "current" state on (otherwise
36781
38293
  * derived from the href/route).
36782
- * @param {"text"|"icon"|"tab"} [props.appearance] - Visual variant
36783
- * (`data-appearance`); `"text"`/`"icon"` drop the link color/underline,
38294
+ * @param {"text"|"icon"|"tab"} [props.variant] - Visual variant
38295
+ * (`data-variant`); `"text"`/`"icon"` drop the link color/underline,
36784
38296
  * `"tab"` renders a tab-like affordance.
36785
38297
  * @param {boolean|"top"|"bottom"|"left"|"right"} [props.currentIndicator] - A
36786
38298
  * bar drawn on the given edge (or bottom when `true`) while current.
36787
38299
  * @param {boolean} [props.currentEffectBold] - Bold the text while current
36788
38300
  * (reserving the bold width so layout doesn't shift).
36789
38301
  * @param {boolean} [props.currentEffectShadow] - Inset-shadow effect while
36790
- * current (used with `appearance="tab"`).
38302
+ * current (used with `variant="tab"`).
36791
38303
  * @param {boolean|import("ignore:preact").ComponentChild} [props.startIcon] - Icon
36792
38304
  * placed before the text.
36793
38305
  * @param {boolean|import("ignore:preact").ComponentChild} [props.endIcon] - Icon
@@ -36856,7 +38368,7 @@ const LinkPlain = props => {
36856
38368
  anchor,
36857
38369
  value = href,
36858
38370
  // visual
36859
- appearance,
38371
+ variant,
36860
38372
  current,
36861
38373
  currentIndicator,
36862
38374
  currentEffectBold,
@@ -36873,6 +38385,7 @@ const LinkPlain = props => {
36873
38385
  props.id = href.slice(1);
36874
38386
  }
36875
38387
  const selectionContext = useContext(SelectionContext);
38388
+ const nav = useContext(NavContext);
36876
38389
  const visited = useIsVisited(href);
36877
38390
  const {
36878
38391
  selection,
@@ -36973,8 +38486,19 @@ const LinkPlain = props => {
36973
38486
  const innerChildren = children || (hrefFallback ? href : children);
36974
38487
  const startIconEl = startIcon;
36975
38488
  const endIconEl = innerEndIcon;
36976
- const currentIndicatorPosition = currentIndicator === true ? "bottom" : currentIndicator;
36977
- const currentIndicatorEl = currentIndicatorPosition === "left" || currentIndicatorPosition === "right" || currentIndicatorPosition === "top" || currentIndicatorPosition === "bottom" ? jsx(LinkCurrentIndicator, {}) : null;
38489
+
38490
+ // Where the bar goes: said here, or once for the whole row by the <Nav>
38491
+ // around this link.
38492
+ const currentIndicatorAsked = currentIndicator ?? nav?.currentIndicator;
38493
+ const currentIndicatorPosition = currentIndicatorAsked === true ? "bottom" : currentIndicatorAsked;
38494
+ const currentIndicatorEl = currentIndicatorPosition === "left" || currentIndicatorPosition === "right" || currentIndicatorPosition === "top" || currentIndicatorPosition === "bottom" ? jsx(LinkCurrentIndicator
38495
+ // Only the bar one can actually see carries the row's name, because a
38496
+ // name belongs to one element at a time and every tab holds a bar. The
38497
+ // browser then has the same thing in two places from one page to the
38498
+ // next, and moves it — which is the whole of "the bar slides".
38499
+ , {
38500
+ viewTransitionName: innerCurrent ? nav?.indicatorName : null
38501
+ }) : null;
36978
38502
  const {
36979
38503
  onClick,
36980
38504
  preventDefault
@@ -36984,9 +38508,24 @@ const LinkPlain = props => {
36984
38508
  color: anchor && !innerChildren ? "inherit" : undefined,
36985
38509
  ...controlRootProps,
36986
38510
  ...controlHostProps,
38511
+ // Everything this component reads for itself is taken off the way out:
38512
+ // what is left goes on the element, and a prop that means something here
38513
+ // means nothing to an <a>. Written one by one rather than pulled out of
38514
+ // props with a rest, because props is also what the control layer above
38515
+ // was handed.
36987
38516
  preventDefault: undefined,
36988
38517
  anchor: undefined,
36989
38518
  revealOnInteraction: undefined,
38519
+ variant: undefined,
38520
+ current: undefined,
38521
+ currentIndicator: undefined,
38522
+ currentEffectBold: undefined,
38523
+ currentEffectShadow: undefined,
38524
+ blankTargetIcon: undefined,
38525
+ anchorIcon: undefined,
38526
+ startIcon: undefined,
38527
+ endIcon: undefined,
38528
+ hrefFallback: undefined,
36990
38529
  onClick: e => {
36991
38530
  onClick?.(e);
36992
38531
  if (preventDefault) {
@@ -37015,7 +38554,7 @@ const LinkPlain = props => {
37015
38554
  // Visual
37016
38555
  ,
37017
38556
 
37018
- "data-appearance": appearance,
38557
+ "data-variant": variant,
37019
38558
  "data-current-effect-bold": currentEffectBold ? "" : undefined,
37020
38559
  "data-current-effect-shadow": currentEffectShadow ? "" : undefined,
37021
38560
  "data-current-indicator-position": currentIndicatorPosition,
@@ -37036,20 +38575,23 @@ const LinkPlain = props => {
37036
38575
  children: [startIconEl, innerChildren, endIconEl]
37037
38576
  });
37038
38577
  };
37039
- const LinkCurrentIndicator = () => {
38578
+ const LinkCurrentIndicator = ({
38579
+ viewTransitionName
38580
+ }) => {
37040
38581
  return jsx("span", {
37041
- className: "navi_current_indicator"
38582
+ className: "navi_current_indicator",
38583
+ style: viewTransitionName ? {
38584
+ viewTransitionName
38585
+ } : undefined
37042
38586
  });
37043
38587
  };
37044
38588
  markAsOutsideTextFlow(LinkCurrentIndicator);
37045
38589
 
37046
- const NavContext = createContext();
37047
- createContext();
37048
-
37049
38590
  installImportMetaCssBuild(import.meta);/**
37050
38591
  * TabList component with support for horizontal and vertical layouts
37051
38592
  * https://dribbble.com/search/tabs
37052
38593
  */
38594
+ let navCount = 0;
37053
38595
  const css$N = /* css */`
37054
38596
  @layer navi {
37055
38597
  .navi_nav {
@@ -37214,6 +38756,22 @@ const NavStyleCSSVars = {
37214
38756
  paddingLeft: "--nav-padding-left",
37215
38757
  background: "--nav-background"
37216
38758
  };
38759
+ /**
38760
+ * @type {import("ignore:preact").FunctionComponent<{
38761
+ * currentIndicator?: boolean|"top"|"bottom"|"left"|"right",
38762
+ * currentIndicatorSlides?: boolean,
38763
+ * }>}
38764
+ * @param {boolean|"top"|"bottom"|"left"|"right"} [props.currentIndicator] - the
38765
+ * bar that says which tab one is on, said once here rather than on every
38766
+ * `<Link>`. A link may still say otherwise for itself.
38767
+ * @param {boolean} [props.currentIndicatorSlides=true] - whether that bar
38768
+ * travels from the tab it was under to the tab it is under now, instead of
38769
+ * going out on one and coming back on the other. It does so by being NAMED,
38770
+ * which is all the browser needs: any change played as a view transition
38771
+ * animates it on the same clock as everything else in that transition. Inside
38772
+ * a `RouteTravel` that means it follows the pages, and the thumb dragging
38773
+ * them, without either of them being told about the other.
38774
+ */
37217
38775
  const Nav = ({
37218
38776
  children,
37219
38777
  spacing,
@@ -37221,11 +38779,23 @@ const Nav = ({
37221
38779
  expand,
37222
38780
  expandX,
37223
38781
  linkBorderRadiusInherit,
38782
+ currentIndicator,
38783
+ currentIndicatorSlides = true,
37224
38784
  panelPosition,
37225
38785
  // "before" or "after": which side the panel sits on, turning the nav into folder tabs
37226
38786
  ...props
37227
38787
  }) => {
37228
38788
  import.meta.css = [css$N, "@jsenv/navi/src/nav/link/nav.jsx"];
38789
+ const indicatorNameRef = useRef(null);
38790
+ if (indicatorNameRef.current === null) {
38791
+ indicatorNameRef.current = `navi-nav-indicator-${++navCount}`;
38792
+ }
38793
+ const navContextValue = useMemo(() => ({
38794
+ currentIndicator,
38795
+ // Read by the link that is current, and by it alone: a name belongs to
38796
+ // one element at a time, and the bar exists in every tab.
38797
+ indicatorName: currentIndicatorSlides ? indicatorNameRef.current : null
38798
+ }), [currentIndicator, currentIndicatorSlides]);
37229
38799
  children = toChildArray(children);
37230
38800
  return jsx(Box, {
37231
38801
  as: "nav",
@@ -37242,7 +38812,7 @@ const Nav = ({
37242
38812
  ...props,
37243
38813
  styleCSSVars: NavStyleCSSVars,
37244
38814
  children: jsx(NavContext.Provider, {
37245
- value: true,
38815
+ value: navContextValue,
37246
38816
  children: children
37247
38817
  })
37248
38818
  });
@@ -39605,8 +41175,8 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39605
41175
  --color-mix: var(--color-mix-light);
39606
41176
  }
39607
41177
 
39608
- /* Checkbox appearance */
39609
- &[data-appearance="checkbox"] {
41178
+ /* Checkbox variant */
41179
+ &[data-variant="checkbox"] {
39610
41180
  .navi_checkbox_marker {
39611
41181
  width: 100%;
39612
41182
  height: 100%;
@@ -39636,8 +41206,8 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39636
41206
  }
39637
41207
  }
39638
41208
 
39639
- /* Switch appearance */
39640
- &[data-appearance="switch"] {
41209
+ /* Switch variant */
41210
+ &[data-variant="switch"] {
39641
41211
  --switch-outer-width: calc(var(--switch-width) + var(--switch-padding));
39642
41212
  --margin: var(--switch-margin);
39643
41213
  --width: var(--switch-outer-width);
@@ -39669,7 +41239,7 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39669
41239
  border-color: transparent;
39670
41240
  }
39671
41241
 
39672
- &[data-appearance="icon"] {
41242
+ &[data-variant="icon"] {
39673
41243
  --margin: 0;
39674
41244
  --width: auto;
39675
41245
  --height: auto;
@@ -39678,7 +41248,7 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39678
41248
  border: none;
39679
41249
  }
39680
41250
 
39681
- &[data-appearance="button"] {
41251
+ &[data-variant="button"] {
39682
41252
  --margin: 0;
39683
41253
  --width: auto;
39684
41254
  --height: auto;
@@ -39755,7 +41325,7 @@ const InputCheckboxFieldInterface = props => {
39755
41325
  const {
39756
41326
  icon,
39757
41327
  switch: switchProp,
39758
- appearance = icon ? "icon" : switchProp ? "switch" : "checkbox",
41328
+ variant = icon ? "icon" : switchProp ? "switch" : "checkbox",
39759
41329
  // "checkbox", "switch", "icon", "button"
39760
41330
  accentColor
39761
41331
  } = props;
@@ -39769,13 +41339,13 @@ const InputCheckboxFieldInterface = props => {
39769
41339
  elementSelector: ".navi_checkbox_accent_probe"
39770
41340
  });
39771
41341
  let visualVnode;
39772
- if (appearance === "icon" || icon) {
41342
+ if (variant === "icon" || icon) {
39773
41343
  visualVnode = jsx("div", {
39774
41344
  className: "navi_checkbox_icon",
39775
41345
  "aria-hidden": "true",
39776
41346
  children: Array.isArray(icon) ? icon[checked ? 1 : 0] : icon
39777
41347
  });
39778
- } else if (appearance === "switch") {
41348
+ } else if (variant === "switch") {
39779
41349
  visualVnode = jsx(SwitchUI, {});
39780
41350
  } else {
39781
41351
  visualVnode = jsx(Box, {
@@ -39796,16 +41366,16 @@ const InputCheckboxFieldInterface = props => {
39796
41366
  // (passsing any custom width/height would auto disable aspectRatio forced by the square prop)
39797
41367
  ,
39798
41368
 
39799
- square: appearance === "button" ? true : undefined,
41369
+ square: variant === "button" ? true : undefined,
39800
41370
  ...checkboxRootProps,
39801
41371
  ref: boxRef,
39802
- appearance: undefined,
41372
+ variant: undefined,
39803
41373
  switch: undefined,
39804
41374
  icon: undefined,
39805
- "data-appearance": appearance,
41375
+ "data-variant": variant,
39806
41376
  baseClassName: "navi_checkbox",
39807
41377
  pseudoStateSelector: ".navi_control_input",
39808
- styleCSSVars: appearance === "switch" ? CheckboxSwitchStyleCSSVars : appearance === "button" ? CheckboxButtonStyleCSSVars : CheckboxStyleCSSVars,
41378
+ styleCSSVars: variant === "switch" ? CheckboxSwitchStyleCSSVars : variant === "button" ? CheckboxButtonStyleCSSVars : CheckboxStyleCSSVars,
39809
41379
  basePseudoState: basePseudoState,
39810
41380
  pseudoClasses: CheckboxPseudoClasses,
39811
41381
  pseudoElements: CheckboxPseudoElements,
@@ -40380,8 +41950,8 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
40380
41950
  }
40381
41951
  }
40382
41952
 
40383
- /* Radio appearance */
40384
- &[data-appearance="radio"] {
41953
+ /* Radio variant */
41954
+ &[data-variant="radio"] {
40385
41955
  display: inline-flex;
40386
41956
  align-items: center;
40387
41957
  justify-content: center;
@@ -40428,16 +41998,16 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
40428
41998
  }
40429
41999
  }
40430
42000
 
40431
- /* Icon appearance */
40432
- &[data-appearance="icon"] {
42001
+ /* Icon variant */
42002
+ &[data-variant="icon"] {
40433
42003
  --width: auto;
40434
42004
  --height: auto;
40435
42005
  --outline-offset: 2px;
40436
42006
  --outline-width: 2px;
40437
42007
  }
40438
42008
 
40439
- /* Button appearance */
40440
- &[data-appearance="button"] {
42009
+ /* Button variant */
42010
+ &[data-variant="button"] {
40441
42011
  --margin: 0;
40442
42012
  --outline-offset: 0px;
40443
42013
  --width: auto;
@@ -40542,18 +42112,18 @@ const InputRadioHeadless = props => {
40542
42112
  ...radioHostProps
40543
42113
  });
40544
42114
  };
40545
- const APPEARANCE_SET = new Set(["icon", "button", "radio"]);
42115
+ const VARIANT_SET = new Set(["icon", "button", "radio"]);
40546
42116
  const InputRadioFieldInterface = props => {
40547
42117
  import.meta.css = [css$E, "@jsenv/navi/src/control/input/input_radio.jsx"];
40548
42118
  const [radioRootProps, radioHostProps] = useCheckableProps(props);
40549
42119
  const {
40550
42120
  icon,
40551
- appearance
42121
+ variant
40552
42122
  } = props;
40553
- let appearanceResolved = appearance || (icon ? "icon" : "radio");
40554
- if (appearance && !APPEARANCE_SET.has(appearance)) {
40555
- console.warn(`InputRadio: unsupported appearance "${appearance}". Falling back to "radio".`);
40556
- appearanceResolved = "radio";
42123
+ let variantResolved = variant || (icon ? "icon" : "radio");
42124
+ if (variant && !VARIANT_SET.has(variant)) {
42125
+ console.warn(`InputRadio: unsupported variant "${variant}". Falling back to "radio".`);
42126
+ variantResolved = "radio";
40557
42127
  }
40558
42128
  const {
40559
42129
  basePseudoState,
@@ -40565,10 +42135,10 @@ const InputRadioFieldInterface = props => {
40565
42135
  elementSelector: ".navi_radio_accent_probe"
40566
42136
  });
40567
42137
  let visualVNode;
40568
- if (appearanceResolved === "icon" || icon) {
42138
+ if (variantResolved === "icon" || icon) {
40569
42139
  visualVNode = Array.isArray(icon) ? icon[checked ? 1 : 0] : icon;
40570
42140
  } else {
40571
- // appearanceResolved === "radio"
42141
+ // variantResolved === "radio"
40572
42142
  visualVNode = jsx(RadioSvg, {});
40573
42143
  }
40574
42144
  return jsxs(Box, {
@@ -40577,16 +42147,16 @@ const InputRadioFieldInterface = props => {
40577
42147
  // (passsing any custom width/height would auto disable aspectRatio forced by the square prop)
40578
42148
  ,
40579
42149
 
40580
- square: appearanceResolved === "button" ? true : undefined,
42150
+ square: variantResolved === "button" ? true : undefined,
40581
42151
  ...radioRootProps,
40582
42152
  ref: boxRef,
40583
42153
  icon: undefined,
40584
- appearance: undefined,
40585
- "data-appearance": appearanceResolved,
42154
+ variant: undefined,
42155
+ "data-variant": variantResolved,
40586
42156
  baseClassName: "navi_radio",
40587
42157
  pseudoStateSelector: ".navi_control_input",
40588
42158
  basePseudoState: basePseudoState,
40589
- styleCSSVars: appearanceResolved === "button" ? RadioButtonStyleCSSVars : RadioStyleCSSVars,
42159
+ styleCSSVars: variantResolved === "button" ? RadioButtonStyleCSSVars : RadioStyleCSSVars,
40590
42160
  pseudoClasses: RadioPseudoClasses,
40591
42161
  pseudoElements: RadioPseudoElements,
40592
42162
  children: [jsx("span", {
@@ -43562,6 +45132,15 @@ installImportMetaCssBuild(import.meta);/**
43562
45132
  * the slides in between fly past — least of all in a tab bar, where they are
43563
45133
  * not a road one travels but places one goes straight to.
43564
45134
  *
45135
+ * A finger (or a mouse) drags the slides too: the track follows the pointer, the
45136
+ * neighbours are brought alongside for the occasion, and letting go either
45137
+ * carries on to the one being pulled in or puts the current one back — the
45138
+ * gesture decides, not the distance alone. It walks ONE AXIS, chosen from the
45139
+ * first few pixels: a diagonal would ask for two travels at once and only one
45140
+ * slide can arrive. What a gesture IS — how far it has to go, who else may
45141
+ * claim it, what letting go says — is read in drag_to_travel.js; what is here is
45142
+ * where the slides stand while it happens.
45143
+ *
43565
45144
  * The slides live INSIDE the box, which is what makes this work for a popup: a
43566
45145
  * dialog and a popover are both promoted to the browser's top layer, so no
43567
45146
  * container of ours could ever hold two of them side by side and translate the
@@ -43569,6 +45148,18 @@ installImportMetaCssBuild(import.meta);/**
43569
45148
  * it is the same component in the document, in a dialog or in a popover.
43570
45149
  */
43571
45150
  const css$A = /* css */`
45151
+ /* Where the picture stands relative to the slide that is current, in boxes
45152
+ (see paintTravelProgress). Declared, so that it is a NUMBER the browser can
45153
+ interpolate: the trait an indicator draws has to travel with the slides,
45154
+ and an undeclared custom property only ever jumps from one value to the
45155
+ next. Inherited, so anything drawn inside the box can read it, and 0 by
45156
+ default — at rest there is nothing to lean towards. */
45157
+ @property --slide-travel-progress {
45158
+ syntax: "<number>";
45159
+ inherits: true;
45160
+ initial-value: 0;
45161
+ }
45162
+
43572
45163
  /* Every slide in the same grid cell: the box then measures itself on the
43573
45164
  LARGEST of them, in both directions, without anything being measured by
43574
45165
  hand — which is also why nothing here resizes as the slides change. Each
@@ -43609,6 +45200,28 @@ const css$A = /* css */`
43609
45200
  outline: none;
43610
45201
  }
43611
45202
 
45203
+ /* What a touch may do here: the axis the slides travel on is taken (it is
45204
+ what the gesture drags), the other one is left to the page — so a
45205
+ carousel in an article is swiped sideways and the article still scrolls
45206
+ under the same finger. A map travelling both ways takes both.
45207
+ A scroller INSIDE a slide is not concerned: touch-action is read up to
45208
+ the scroll container the gesture would move, so a row that scrolls
45209
+ sideways within a slide still scrolls sideways. */
45210
+ &[data-travel-by-drag="x"] {
45211
+ touch-action: pan-y;
45212
+ }
45213
+ &[data-travel-by-drag="y"] {
45214
+ touch-action: pan-x;
45215
+ }
45216
+ &[data-travel-by-drag="xy"] {
45217
+ touch-action: none;
45218
+ }
45219
+ /* A drag is not a selection: without this a mouse pulling a slide paints
45220
+ the text it passes over blue. */
45221
+ &[data-slide-dragging] {
45222
+ user-select: none;
45223
+ }
45224
+
43612
45225
  /* Outside the box, which is where an outline is drawn by default: nothing
43613
45226
  inside can paint over it (the slides are all within), and this box's own
43614
45227
  overflow does not clip it either — an element's outline is not its own
@@ -43691,31 +45304,48 @@ const css$A = /* css */`
43691
45304
  // own box, because that is what its percentages resolve to.
43692
45305
  const ratioOfOneTravel = (track, from, to, targetBefore) => {
43693
45306
  const box = track.getBoundingClientRect();
43694
- const readOffset = offset => {
43695
- if (!offset || offset === "none") {
43696
- return {
43697
- x: 0,
43698
- y: 0
43699
- };
43700
- }
43701
- const [x = "0", y = "0"] = String(offset).trim().split(/\s+/);
43702
- const toPx = (value, size) => value.endsWith("%") ? parseFloat(value) / 100 * size : parseFloat(value) || 0;
43703
- return {
43704
- x: toPx(x, box.width),
43705
- y: toPx(y, box.height)
43706
- };
43707
- };
43708
45307
  const distance = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
43709
- const target = readOffset(to);
43710
- const asked = distance(readOffset(targetBefore), target);
45308
+ const target = offsetToPx(to, box);
45309
+ const asked = distance(offsetToPx(targetBefore, box), target);
43711
45310
  if (!asked) {
43712
45311
  return 1;
43713
45312
  }
43714
- const left = distance(readOffset(from), target);
45313
+ const left = distance(offsetToPx(from, box), target);
43715
45314
  const ratio = left / asked;
43716
45315
  return ratio > 1 ? 1 : ratio;
43717
45316
  };
43718
45317
 
45318
+ // A translate ("-100% 0%", "-260px 0px", "none") as two numbers of pixels.
45319
+ // Percentages are the size of the box, which is what a translate resolves them
45320
+ // against — so an offset written either way can be measured against another.
45321
+ // Where the track stands right now, in pixels, read off the box it draws in
45322
+ // rather than off the value that moves it: mid-animation the browser reports
45323
+ // that value as a calc() of a percentage and a length ("calc(-43% - 119px)"),
45324
+ // which no simple parse survives — read as 0, it puts the gesture a whole
45325
+ // travel away from what the eye is looking at.
45326
+ const trackOffsetPx = (track, containerElement) => {
45327
+ const trackRect = track.getBoundingClientRect();
45328
+ const containerRect = containerElement.getBoundingClientRect();
45329
+ return {
45330
+ x: trackRect.x - containerRect.x,
45331
+ y: trackRect.y - containerRect.y
45332
+ };
45333
+ };
45334
+ const offsetToPx = (offset, box) => {
45335
+ if (!offset || offset === "none") {
45336
+ return {
45337
+ x: 0,
45338
+ y: 0
45339
+ };
45340
+ }
45341
+ const [x = "0", y = "0"] = String(offset).trim().split(/\s+/);
45342
+ const toPx = (value, size) => value.endsWith("%") ? parseFloat(value) / 100 * size : parseFloat(value) || 0;
45343
+ return {
45344
+ x: toPx(x, box.width),
45345
+ y: toPx(y, box.height)
45346
+ };
45347
+ };
45348
+
43719
45349
  // A press landing while the track is already travelling: what is playing is
43720
45350
  // sent home in a fifth of the time it has left, and the press it could not take
43721
45351
  // yet is taken as soon as it lands. A press has to be FELT — nudging the pace
@@ -43735,6 +45365,54 @@ const hurryTravel = animation => {
43735
45365
  animation.playbackRate = rate > HURRY_RATE_MAX ? HURRY_RATE_MAX : rate;
43736
45366
  };
43737
45367
 
45368
+ // Which axes an option opens, from a prop that says either yes/no or the axes
45369
+ // themselves — and never more than the map has: a `travelByScroll="y"` on a row
45370
+ // of slides opens nothing, because there is nothing that way to open.
45371
+ const axesAllowedBy = (option, mapAxes) => {
45372
+ if (!option || !mapAxes) {
45373
+ return null;
45374
+ }
45375
+ if (option === true) {
45376
+ return mapAxes;
45377
+ }
45378
+ let allowed = "";
45379
+ for (const axis of mapAxes) {
45380
+ if (option.includes(axis)) {
45381
+ allowed += axis;
45382
+ }
45383
+ }
45384
+ return allowed || null;
45385
+ };
45386
+
45387
+ // Which axes the map has anything on, read from the layout alone: it is what
45388
+ // says which way a touch may travel, and a touch is answered before any of the
45389
+ // DOM below has been looked at.
45390
+ const travelAxesOf = layout => {
45391
+ if (typeof layout === "string") {
45392
+ return layout === "column" ? "y" : "x";
45393
+ }
45394
+ const {
45395
+ placeOf
45396
+ } = parseAreas(layout);
45397
+ let hasX = false;
45398
+ let hasY = false;
45399
+ for (const {
45400
+ x,
45401
+ y
45402
+ } of placeOf.values()) {
45403
+ if (x > 0) {
45404
+ hasX = true;
45405
+ }
45406
+ if (y > 0) {
45407
+ hasY = true;
45408
+ }
45409
+ }
45410
+ if (hasX && hasY) {
45411
+ return "xy";
45412
+ }
45413
+ return hasY ? "y" : "x";
45414
+ };
45415
+
43738
45416
  // The ways out of a slide: whatever carries a travel command, the built-in
43739
45417
  // chevrons (SlideNavButton) and anything a caller wired by hand alike. They are
43740
45418
  // the container's chrome, not its content — see rememberFocus.
@@ -43818,6 +45496,26 @@ const durationToMs = duration => {
43818
45496
  }
43819
45497
  return String(duration).trimEnd().endsWith("ms") ? number : number * 1000;
43820
45498
  };
45499
+
45500
+ // What asked for a travel, read off the event that carried it: the hand said
45501
+ // "somewhere over there" (a gesture one browses with), or it said a name (a tab
45502
+ // pressed, a key, a command — a place aimed at). Nothing at all when the travel
45503
+ // came from code, which has no interaction to speak of.
45504
+ const causeOfEvent = event => {
45505
+ if (!event) {
45506
+ return "code";
45507
+ }
45508
+ const {
45509
+ type
45510
+ } = event;
45511
+ if (type === "pointerup" || type === "pointercancel") {
45512
+ return "drag";
45513
+ }
45514
+ if (type === "keydown" || type === "keyup") {
45515
+ return "keyboard";
45516
+ }
45517
+ return "command";
45518
+ };
43821
45519
  const readArea = slideElement => slideElement.getAttribute("data-slide-area") || slideElement.id || "";
43822
45520
 
43823
45521
  /**
@@ -43845,7 +45543,21 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
43845
45543
  * it says where one starts, not where one is — say `current` for that.
43846
45544
  * Without it the first slide is the one shown, the way a stack of pages opens
43847
45545
  * on its first page.
43848
- * @param {(area: string) => void} [props.onCurrentChange]
45546
+ * @param {(area: string, detail: {cause: "drag"|"keyboard"|"command"|"code", event: Event}) => void|false|Promise<void|false>} [props.onCurrentChange]
45547
+ * - the slide being shown has changed. `cause` says what asked for it, which
45548
+ * is what tells a place browsed past from a place aimed at: a caller writing
45549
+ * this into the URL pushes a history entry for a tab that was pressed and
45550
+ * replaces the current one for a slide that was dragged, so three swipes back
45551
+ * and forth do not bury the way out of the page.
45552
+ * Answer `false` to REFUSE the change and the slide goes back where it came
45553
+ * from — a guard that says no, a session that is gone. A promise refuses it
45554
+ * late, once whatever it had to ask has answered; the travel plays meanwhile
45555
+ * and is undone if the answer is no.
45556
+ * @param {"now"|"rest"} [props.commit="now"] - when the change is told.
45557
+ * "rest" waits for the travel to be over, and lets the container hold the
45558
+ * slide it is going to meanwhile: the picture moves with the finger and the
45559
+ * caller is told once, at the end. For a change that costs something or shows
45560
+ * somewhere — the URL, a server — and cannot be asked for per frame.
43849
45561
  * @param {boolean} [props.loop] - the slides are a window over something
43850
45562
  * endless (days, months, a carousel) rather than places one stays at. A
43851
45563
  * travel plays as usual and then the window comes back to the slide it rests
@@ -43858,12 +45570,28 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
43858
45570
  * one that just travelled there. Called once the travel is over, and in the
43859
45571
  * same render as the return to rest — anything later shows the old content
43860
45572
  * for a frame.
43861
- * @param {boolean} [props.keyboardTravel=true] - whether the arrows (and
43862
- * Home/End) walk the map. On by default: a map one can see is a map one
43863
- * expects to walk. Off when the arrows mean something else where these slides
43864
- * are a list of choices one moves through, a picker whose screens are
43865
- * steps rather than places so the keys keep the meaning the content gives
43866
- * them, and travelling stays something one asks for (a button, a command).
45573
+ * Each way of travelling can be shut off, or narrowed to one axis: `true`
45574
+ * (every axis the map has), `false`, `"x"`, `"y"`, `"xy"`.
45575
+ *
45576
+ * @param {boolean|"x"|"y"|"xy"} [props.travelByKeyboard=true] - whether the
45577
+ * arrows (and Home/End) walk the map. On by default: a map one can see is a
45578
+ * map one expects to walk. Off when the arrows mean something else where
45579
+ * these slides are — a list of choices one moves through, a picker whose
45580
+ * screens are steps rather than places — so the keys keep the meaning the
45581
+ * content gives them, and travelling stays something one asks for (a button,
45582
+ * a command).
45583
+ * @param {boolean|"x"|"y"|"xy"} [props.travelByDrag=true] - whether a pointer
45584
+ * dragging the slides travels. On by default: slides side by side are
45585
+ * something one expects to push around with a thumb. Off where the gesture
45586
+ * belongs to the content (a canvas one draws on, a map one pans), or where
45587
+ * the slides are steps of a form rather than a row one browses.
45588
+ * @param {boolean|"x"|"y"|"xy"} [props.travelByScroll="x"] - whether a wheel
45589
+ * pushing the box travels, one slide per push. Sideways only by default,
45590
+ * because a page is hardly ever scrollable that way: a sideways scroll over
45591
+ * the box can only have been meant for the box. Down the page it is the
45592
+ * page's OWN gesture, and taking it would move a slide under someone who was
45593
+ * scrolling the document — so a map that travels vertically has to be told
45594
+ * (`travelByScroll` / `"y"` / `"xy"`) before a wheel moves it.
43867
45595
  * @param {string} [props.duration="300ms"] - how long a slide change takes.
43868
45596
  */
43869
45597
  const SlideContainer = ({
@@ -43871,9 +45599,12 @@ const SlideContainer = ({
43871
45599
  current: currentProp,
43872
45600
  defaultCurrent,
43873
45601
  onCurrentChange,
45602
+ commit = "now",
43874
45603
  loop,
43875
45604
  onLoop,
43876
- keyboardTravel = true,
45605
+ travelByKeyboard = true,
45606
+ travelByDrag = true,
45607
+ travelByScroll = "x",
43877
45608
  duration = "300ms",
43878
45609
  children,
43879
45610
  ...rest
@@ -43887,6 +45618,15 @@ const SlideContainer = ({
43887
45618
  // The AREA of the slide being shown, not its rank: a rank would be wrong the
43888
45619
  // moment a slide appears before it, and there is nothing to renumber here.
43889
45620
  const [currentAreaState, setCurrentAreaState] = useState(defaultCurrent);
45621
+ // The slide this container is travelling to while its controller has not been
45622
+ // told yet (commit="rest"): for the length of that travel the container is
45623
+ // ahead of whoever holds `current`, and this is where it keeps its own answer
45624
+ // — dropped as soon as the controller has caught up, or the change was
45625
+ // refused.
45626
+ const [provisionalArea, setProvisionalArea] = useState(null);
45627
+ // The change waiting for the travel to be over: what to tell the caller, and
45628
+ // what to put back if they refuse it.
45629
+ const commitAtRestRef = useRef(null);
43890
45630
  // Where the window is while it rolls, and nothing more: a looping container
43891
45631
  // rests where it rested before (below), so this is the travel itself rather
43892
45632
  // than a change of slide.
@@ -43933,8 +45673,28 @@ const SlideContainer = ({
43933
45673
  // A focus transfer read off the interaction that asked for the travel, kept
43934
45674
  // until the slide it is meant for holds its final DOM (see handOverFocus).
43935
45675
  const focusHandOverRef = useRef(null);
43936
- const current = rollingArea ?? currentProp ?? currentAreaState;
45676
+ // The gesture in hand, from the pointer that started it to the slides it
45677
+ // brought alongside. Null when no finger is on the box.
45678
+ const dragRef = useRef(null);
45679
+ // Where the travel about to be drawn departs from, when that is not where the
45680
+ // track rests: a slide let go of halfway carries on from under the finger.
45681
+ // Read and dropped by the layout effect, which is the one drawing it.
45682
+ const travelFromRef = useRef(null);
45683
+ // The same fact for the indicator (--slide-travel-progress): how far the
45684
+ // picture is from the slide ARRIVING when the travel starts, in boxes. Null
45685
+ // for a travel nobody dragged, where a whole box is what is left to close.
45686
+ const travelProgressFromRef = useRef(null);
45687
+ const progressAnimationRef = useRef(null);
45688
+ const current = rollingArea ?? provisionalArea ?? currentProp ?? currentAreaState;
43937
45689
  const vertical = layout === "column";
45690
+ // What the map has, and what each way of asking is allowed to use of it.
45691
+ const mapAxes = travelAxesOf(layout);
45692
+ const dragAxes = axesAllowedBy(travelByDrag, mapAxes);
45693
+ const scrollAxes = axesAllowedBy(travelByScroll, mapAxes);
45694
+ const keyboardAxes = axesAllowedBy(travelByKeyboard, mapAxes);
45695
+ // What must not spill onto the page behind the box: every axis a gesture of
45696
+ // ours can take, whichever gesture it is.
45697
+ const travelAxes = dragAxes && scrollAxes ? axesAllowedBy(`${dragAxes}${scrollAxes}`, mapAxes) : dragAxes || scrollAxes;
43938
45698
  // Which required slides have been answered (see Slide's own `required`). Held
43939
45699
  // here rather than in each slide because answering one says something about
43940
45700
  // the others: the steps after it were answered about a state that has just
@@ -43953,6 +45713,19 @@ const SlideContainer = ({
43953
45713
  // one before it wrote, not what the last render saw.
43954
45714
  const cameFromRef = useRef({});
43955
45715
 
45716
+ // The controller has caught up with the slide the container went to on its
45717
+ // own (commit="rest"): there are no longer two answers to give, so the
45718
+ // container gives its own up rather than holding a copy that can go stale.
45719
+ useLayoutEffect(() => {
45720
+ if (provisionalArea === null) {
45721
+ return;
45722
+ }
45723
+ const heldOutside = currentProp ?? currentAreaState;
45724
+ if (heldOutside === provisionalArea) {
45725
+ setProvisionalArea(null);
45726
+ }
45727
+ }, [provisionalArea, currentProp, currentAreaState]);
45728
+
43956
45729
  // The travel is given back as soon as the picture it must not animate has
43957
45730
  // been painted: one frame with it off is all it takes.
43958
45731
  useLayoutEffect(() => {
@@ -44049,6 +45822,9 @@ const SlideContainer = ({
44049
45822
  }
44050
45823
  stageRef.current = null;
44051
45824
  trackAnimationRef.current = null;
45825
+ // At rest the picture IS the current slide, whatever the last gesture wrote
45826
+ // there: an indicator has nothing left to lean towards.
45827
+ paintTravelProgress(0);
44052
45828
  for (const slideElement of slideElements) {
44053
45829
  const {
44054
45830
  x,
@@ -44072,6 +45848,17 @@ const SlideContainer = ({
44072
45848
  const offset = `${-x * 100}% ${-y * 100}%`;
44073
45849
  offsetRef.current = offset;
44074
45850
  track.style.setProperty("--slide-container-offset", offset);
45851
+ // Arrived, so the change can be told (commit="rest"): the picture is at
45852
+ // rest and whatever the caller does with it — write the URL, ask a server —
45853
+ // costs the gesture nothing anymore.
45854
+ const commitAtRest = commitAtRestRef.current;
45855
+ if (commitAtRest && commitAtRest.area === currentArea) {
45856
+ commitAtRestRef.current = null;
45857
+ answerCurrentChange(onCurrentChange(commitAtRest.area, {
45858
+ cause: commitAtRest.cause,
45859
+ event: commitAtRest.event
45860
+ }), commitAtRest.leftArea);
45861
+ }
44075
45862
  };
44076
45863
 
44077
45864
  // Everything positional is decided here, from the DOM, once per render: where
@@ -44104,6 +45891,9 @@ const SlideContainer = ({
44104
45891
  stageRef.current = null;
44105
45892
  }
44106
45893
  let stage = stageRef.current;
45894
+ // Which way this travel goes, kept for the indicator: a whole box lies
45895
+ // between the picture and the slide arriving, on the axis it walks.
45896
+ let travelStep = null;
44107
45897
  const drawnArea = stage ? stage.area : drawnAreaRef.current;
44108
45898
  const travelStarts = !noTravel && durationMs > 0 && drawnArea !== undefined && drawnArea !== currentArea && slideElements.some(slideElement => readArea(slideElement) === drawnArea);
44109
45899
  if (travelStarts) {
@@ -44115,6 +45905,7 @@ const SlideContainer = ({
44115
45905
  x: Math.sign(realPlaceOf(currentArea).x - realPlaceOf(drawnArea).x),
44116
45906
  y: Math.sign(realPlaceOf(currentArea).y - realPlaceOf(drawnArea).y)
44117
45907
  };
45908
+ travelStep = step;
44118
45909
  // Kept, not replaced: the slides a chain of quick presses has already
44119
45910
  // left behind are still trailing off screen, and taking them off stage
44120
45911
  // now would blink them out mid-travel.
@@ -44191,7 +45982,15 @@ const SlideContainer = ({
44191
45982
  // fetch, and having it in hand is also what allows the pace below.
44192
45983
  const travelInFlight = trackAnimationRef.current?.playState === "running";
44193
45984
  const offsetOnScreen = travelInFlight ? getComputedStyle(track).translate : undefined;
44194
- const offsetBefore = offsetOnScreen ?? offsetRef.current;
45985
+ // …and where a slide let go of halfway was left, which is the same fact
45986
+ // said by the gesture that put it there: the track is at rest as far as any
45987
+ // animation is concerned, so nothing else could tell.
45988
+ const offsetDragged = travelFromRef.current;
45989
+ travelFromRef.current = null;
45990
+ const offsetBefore = offsetDragged ?? offsetOnScreen ?? offsetRef.current;
45991
+ // The travel that was ASKED for is still one box, whatever is left of it to
45992
+ // cover: a slide dragged most of the way there finishes in what is left of
45993
+ // the duration rather than taking a full one over a few pixels.
44195
45994
  const offsetTargetBefore = offsetRef.current;
44196
45995
  offsetRef.current = offset;
44197
45996
  // Where the track ends up, always — the animation below only covers the way
@@ -44206,9 +46005,10 @@ const SlideContainer = ({
44206
46005
  // this only ever shortens it (a longer travel is not made slower, which
44207
46006
  // would make a two-box move drag).
44208
46007
  const travelRatio = ratioOfOneTravel(track, offsetBefore, offset, offsetTargetBefore);
44209
- // Already moving, so no ease-in to play: it would stall the track for an
44210
- // instant right where the eye is following it.
44211
- const easing = travelInFlight ? "ease-out" : "ease";
46008
+ // Already moving under an animation or under a finger that has just
46009
+ // let go so there is no ease-in to play: it would stall the track for
46010
+ // an instant right where the eye is following it.
46011
+ const easing = travelInFlight || offsetDragged ? "ease-out" : "ease";
44212
46012
  // Cancelled rather than layered: two animations on the same property
44213
46013
  // would blend, and what one sees then is neither of the two moves.
44214
46014
  trackAnimationRef.current?.cancel();
@@ -44220,6 +46020,11 @@ const SlideContainer = ({
44220
46020
  duration: durationMs * travelRatio,
44221
46021
  easing
44222
46022
  });
46023
+ // The trait travels with the slides: from where the gesture left it when
46024
+ // there was one, from a whole box away when the travel was asked for.
46025
+ const progressFrom = travelProgressFromRef.current ?? (travelStep ? travelStep.x || travelStep.y : 0);
46026
+ travelProgressFromRef.current = null;
46027
+ animateTravelProgress(progressFrom, durationMs * travelRatio, easing);
44223
46028
  // Presses still waiting behind this one: it is already late, so it is
44224
46029
  // sent home at once rather than played out at the pace of someone who
44225
46030
  // has stopped pressing. Someone pressing → four times is asking to be
@@ -44233,11 +46038,12 @@ const SlideContainer = ({
44233
46038
  trackAnimationRef.current.finished.then(settleTravel, () => {
44234
46039
  // cancelled by the next travel — that one carries the stage on
44235
46040
  });
44236
- } else if (stage && trackAnimationRef.current?.playState !== "running") {
46041
+ } else if (stage && !dragRef.current?.axis && trackAnimationRef.current?.playState !== "running") {
44237
46042
  // Staged with nothing left to play: a travel that was drawn and then had
44238
46043
  // its animation taken away (a duration set to 0, a re-render landing
44239
46044
  // between the two). Struck at once rather than left standing, since the
44240
- // thing it was standing for is over.
46045
+ // thing it was standing for is over. A gesture in hand is a stage that is
46046
+ // standing for something — the finger still holding it.
44241
46047
  settleTravel();
44242
46048
  }
44243
46049
  // A window waiting for its travel to be over (see goToArea's own loop
@@ -44282,6 +46088,9 @@ const SlideContainer = ({
44282
46088
  if (!stageRef.current) {
44283
46089
  drawnAreaRef.current = currentArea;
44284
46090
  }
46091
+ // The finger has the last word: everything above drew the map at rest, and
46092
+ // where the track actually is right now is where the gesture put it.
46093
+ paintDrag();
44285
46094
  });
44286
46095
 
44287
46096
  /**
@@ -44406,11 +46215,62 @@ const SlideContainer = ({
44406
46215
  };
44407
46216
  return true;
44408
46217
  }
46218
+ const leftArea = readArea(currentElement);
44409
46219
  setCurrentAreaState(area);
44410
- onCurrentChange?.(area);
46220
+ if (!onCurrentChange) {
46221
+ return true;
46222
+ }
46223
+ // What asked for this, read off the interaction rather than carried down
46224
+ // from every caller: it is a fact about the event, and the event is here.
46225
+ // A caller writing the change somewhere that keeps a trace — the URL, a
46226
+ // history — needs it to know whether a place was aimed at or browsed past.
46227
+ const cause = causeOfEvent(event);
46228
+ if (commit === "rest") {
46229
+ // The travel first, the change once it is over: a caller putting it
46230
+ // somewhere expensive or visible (the URL, the address bar, a server)
46231
+ // must not be asked for it sixty times a second, and the picture must not
46232
+ // wait for it either. The container holds the slide it is travelling to
46233
+ // until the answer comes — being ahead of its controller for the length of
46234
+ // one travel is the whole point.
46235
+ setProvisionalArea(area);
46236
+ commitAtRestRef.current = {
46237
+ area,
46238
+ leftArea,
46239
+ cause,
46240
+ event
46241
+ };
46242
+ return true;
46243
+ }
46244
+ answerCurrentChange(onCurrentChange(area, {
46245
+ cause,
46246
+ event
46247
+ }), leftArea);
44411
46248
  return true;
44412
46249
  };
44413
46250
 
46251
+ // What a caller says back about a change it was told about: nothing, or a
46252
+ // refusal. `false` refuses it — a guard that says no, a session that is gone —
46253
+ // and a promise refuses it late, once whatever it had to ask has answered. A
46254
+ // refused change is undone here, so what one sees never disagrees with what
46255
+ // the caller holds: the slide goes back where it came from.
46256
+ const answerCurrentChange = (answer, leftArea) => {
46257
+ if (answer === false) {
46258
+ goBackToRefusedArea(leftArea);
46259
+ return;
46260
+ }
46261
+ if (answer && typeof answer.then === "function") {
46262
+ answer.then(value => {
46263
+ if (value === false) {
46264
+ goBackToRefusedArea(leftArea);
46265
+ }
46266
+ });
46267
+ }
46268
+ };
46269
+ const goBackToRefusedArea = leftArea => {
46270
+ setProvisionalArea(null);
46271
+ setCurrentAreaState(leftArea);
46272
+ };
46273
+
44414
46274
  // The press kept during a roll, taken once the window rests and the travel is
44415
46275
  // given back (noTravel off): by direction when there was one, so it is read
44416
46276
  // against the map as it is NOW — the content moved one step under the window
@@ -44567,6 +46427,464 @@ const SlideContainer = ({
44567
46427
  const moveNext = event => vertical ? move(0, 1, event) || move(1, 0, event) : move(1, 0, event) || move(0, 1, event);
44568
46428
  const movePrevious = event => vertical ? move(0, -1, event) || move(-1, 0, event) : move(-1, 0, event) || move(0, -1, event);
44569
46429
 
46430
+ // Where the track is right now, as the gesture left it: the resting place of
46431
+ // the slide being dragged, plus what the pointer has pulled since.
46432
+ const paintDrag = () => {
46433
+ const drag = dragRef.current;
46434
+ const track = trackRef.current;
46435
+ // Nothing to paint for a pointer that is only resting on the box: until it
46436
+ // has an axis a gesture has moved nothing and knows no geometry.
46437
+ if (!drag || !drag.axis || !track) {
46438
+ return;
46439
+ }
46440
+ const x = drag.baseOffset.x + drag.pull.x;
46441
+ const y = drag.baseOffset.y + drag.pull.y;
46442
+ drag.offset = `${x}px ${y}px`;
46443
+ track.style.setProperty("--slide-container-offset", drag.offset);
46444
+ paintTravelProgress(drag.progress, drag.areaPulled);
46445
+ };
46446
+
46447
+ // Where the picture stands relative to the slide that is CURRENT, in boxes:
46448
+ // 0 on it, +1 one whole box before it, -1 one box after. Written on the
46449
+ // container so an indicator drawn inside the box — a tab bar, a dot row, a
46450
+ // trait — follows the finger in CSS alone, with nothing measured and no
46451
+ // render per frame. Said about the current slide rather than about the
46452
+ // gesture, so the number stays continuous when the travel commits and the
46453
+ // current slide changes under it.
46454
+ const paintTravelProgress = (progress, area) => {
46455
+ const containerEl = containerRef.current;
46456
+ if (!containerEl) {
46457
+ return;
46458
+ }
46459
+ if (!progress) {
46460
+ containerEl.style.removeProperty("--slide-travel-progress");
46461
+ containerEl.removeAttribute("data-slide-travel-to");
46462
+ return;
46463
+ }
46464
+ containerEl.style.setProperty("--slide-travel-progress", progress);
46465
+ if (area) {
46466
+ containerEl.setAttribute("data-slide-travel-to", area);
46467
+ } else {
46468
+ containerEl.removeAttribute("data-slide-travel-to");
46469
+ }
46470
+ };
46471
+
46472
+ // The indicator, brought home at the pace of the travel it belongs to: the
46473
+ // same duration and the same easing as the track, so the trait and the slides
46474
+ // are one movement. The value it lands on is the one nothing writes (0), so
46475
+ // the animation is left to fall away on its own.
46476
+ const animateTravelProgress = (from, durationMs, easing) => {
46477
+ const containerEl = containerRef.current;
46478
+ progressAnimationRef.current?.cancel();
46479
+ progressAnimationRef.current = null;
46480
+ paintTravelProgress(0);
46481
+ if (!containerEl || !from || !durationMs) {
46482
+ return;
46483
+ }
46484
+ progressAnimationRef.current = containerEl.animate([{
46485
+ "--slide-travel-progress": from
46486
+ }, {
46487
+ "--slide-travel-progress": 0
46488
+ }], {
46489
+ duration: durationMs,
46490
+ easing
46491
+ });
46492
+ progressAnimationRef.current.finished.then(() => {
46493
+ progressAnimationRef.current = null;
46494
+ }, () => {
46495
+ // cancelled by the next travel — that one says where the trait goes
46496
+ });
46497
+ };
46498
+
46499
+ // The two slides the gesture can bring in, placed one box either side of the
46500
+ // one being dragged — the same stage a travel builds, except that both ends
46501
+ // are set up at once because the finger has not said yet which way it goes.
46502
+ const stageDrag = drag => {
46503
+ const {
46504
+ slideElements,
46505
+ placeOf
46506
+ } = readMap();
46507
+ const step = drag.axis === "x" ? {
46508
+ x: 1,
46509
+ y: 0
46510
+ } : {
46511
+ x: 0,
46512
+ y: 1
46513
+ };
46514
+ const placeByArea = new Map();
46515
+ placeByArea.set(drag.area, drag.basePlace);
46516
+ if (drag.areaBack) {
46517
+ placeByArea.set(drag.areaBack, {
46518
+ x: drag.basePlace.x - step.x,
46519
+ y: drag.basePlace.y - step.y
46520
+ });
46521
+ }
46522
+ if (drag.areaOn) {
46523
+ placeByArea.set(drag.areaOn, {
46524
+ x: drag.basePlace.x + step.x,
46525
+ y: drag.basePlace.y + step.y
46526
+ });
46527
+ }
46528
+ stageRef.current = {
46529
+ placeByArea,
46530
+ area: drag.area
46531
+ };
46532
+ for (const slideElement of slideElements) {
46533
+ const area = readArea(slideElement);
46534
+ const {
46535
+ x,
46536
+ y
46537
+ } = placeByArea.get(area) || placeOf.get(area) || {
46538
+ x: 0,
46539
+ y: 0
46540
+ };
46541
+ slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
46542
+ slideElement.toggleAttribute("data-slide-offstage", !placeByArea.has(area));
46543
+ }
46544
+ };
46545
+
46546
+ // Let go of without enough of a gesture to travel: the slide comes back to
46547
+ // where it was, over the distance it was pulled — so a slide barely moved
46548
+ // snaps back and one dragged most of the way there takes its time.
46549
+ const returnToRest = drag => {
46550
+ const track = trackRef.current;
46551
+ if (!track) {
46552
+ return;
46553
+ }
46554
+ const restOffset = `${drag.baseOffset.x}px ${drag.baseOffset.y}px`;
46555
+ const durationMs = durationToMs(duration);
46556
+ const pulled = Math.abs(drag.pull[drag.axis]);
46557
+ const size = drag.axis === "x" ? drag.box.width : drag.box.height;
46558
+ trackAnimationRef.current?.cancel();
46559
+ trackAnimationRef.current = null;
46560
+ track.style.setProperty("--slide-container-offset", restOffset);
46561
+ if (!durationMs || !pulled) {
46562
+ paintTravelProgress(0);
46563
+ settleTravel();
46564
+ return;
46565
+ }
46566
+ animateTravelProgress(drag.progress, durationMs * (pulled / size), "ease-out");
46567
+ const animation = track.animate([{
46568
+ translate: drag.offset
46569
+ }, {
46570
+ translate: restOffset
46571
+ }], {
46572
+ duration: durationMs * (pulled / size),
46573
+ easing: "ease-out"
46574
+ });
46575
+ trackAnimationRef.current = animation;
46576
+ animation.finished.then(settleTravel, () => {
46577
+ // cancelled by a travel asked for since — that one carries the stage on
46578
+ });
46579
+ };
46580
+
46581
+ // A travel that is playing when a gesture arrives is STOPPED where it stands,
46582
+ // before the gesture has said anything about itself. Not at the first pixels
46583
+ // that decide an axis: over those the slides go on at their own speed under a
46584
+ // hand already resting on them, and when the gesture finally takes them they
46585
+ // are pinned to a hand moving at a quite different pace — the slide does not
46586
+ // jump, it stops dead, which is what one reads as a jolt and as "it got away
46587
+ // from me".
46588
+ // A gesture that turns out to be nothing lets the travel carry on from where
46589
+ // it was caught (see onGiveUp).
46590
+ const catchTravelInFlight = () => {
46591
+ const trackElement = trackRef.current;
46592
+ const travelCaught = trackAnimationRef.current;
46593
+ if (!travelCaught || travelCaught.playState !== "running") {
46594
+ return null;
46595
+ }
46596
+ const onScreenPx = trackOffsetPx(trackElement, containerRef.current);
46597
+ const offsetOnScreen = `${onScreenPx.x}px ${onScreenPx.y}px`;
46598
+ travelCaught.cancel();
46599
+ // Where it was, held: cancelling an animation puts the track back on the
46600
+ // value underneath it, which is the far end of the travel — the very jump
46601
+ // this is about.
46602
+ trackElement.style.setProperty("--slide-container-offset", offsetOnScreen);
46603
+ return {
46604
+ trackElement,
46605
+ onScreenPx,
46606
+ offsetOnScreen,
46607
+ offsetTarget: offsetRef.current
46608
+ };
46609
+ };
46610
+ // Which way a caught travel was going: a hand reaching for something moving
46611
+ // has no axis left to decide, and a first pixel of tremor read as one gives
46612
+ // the gesture up — and lets go of what it just caught.
46613
+ const axisOfCaughtTravel = caught => {
46614
+ const boxRect = caught.trackElement.getBoundingClientRect();
46615
+ const targetPx = offsetToPx(caught.offsetTarget, boxRect);
46616
+ const towardsX = Math.abs(targetPx.x - caught.onScreenPx.x);
46617
+ const towardsY = Math.abs(targetPx.y - caught.onScreenPx.y);
46618
+ return towardsX >= towardsY ? "x" : "y";
46619
+ };
46620
+
46621
+ // What a travel gesture does to the slides, whoever asked for it: a pointer
46622
+ // dragging the box and a wheel pushing it sideways ask for the same travel,
46623
+ // so they are answered by the same callbacks and only the reading of the
46624
+ // input differs (see drag_to_travel.js). The rules of the gesture are read
46625
+ // there, the geometry of the slides here.
46626
+ const createTravelHandlers = caughtAtStart => {
46627
+ let caughtTravel = caughtAtStart;
46628
+ // The travel in hand, as the slides see it: where the one being dragged
46629
+ // stands, what is either side of it, and how far the gesture has taken it.
46630
+ const drag = {
46631
+ axis: null,
46632
+ area: null,
46633
+ areaBack: null,
46634
+ areaOn: null,
46635
+ areaPulled: null,
46636
+ box: null,
46637
+ basePlace: null,
46638
+ baseOffset: null,
46639
+ pull: {
46640
+ x: 0,
46641
+ y: 0
46642
+ },
46643
+ progress: 0,
46644
+ offset: null,
46645
+ gesture: null
46646
+ };
46647
+ return {
46648
+ drag,
46649
+ onStart: ({
46650
+ axis,
46651
+ sign,
46652
+ target
46653
+ }) => {
46654
+ const areaBack = axis === "x" ? areaTowards(-1, 0) : areaTowards(0, -1);
46655
+ const areaOn = axis === "x" ? areaTowards(1, 0) : areaTowards(0, 1);
46656
+ // Everything positional is read HERE rather than when the pointer
46657
+ // landed: the travel that was playing then may have arrived since, and
46658
+ // it is what the slides are doing at the moment the gesture takes them
46659
+ // over that the gesture must carry on from.
46660
+ const track = trackRef.current;
46661
+ const {
46662
+ slideElements,
46663
+ placeOf
46664
+ } = readMap();
46665
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
46666
+ const box = track.getBoundingClientRect();
46667
+ if (!areaBack && !areaOn || !currentElement || !box.width || !box.height ||
46668
+ // Something else with a better claim on the gesture: a scroller
46669
+ // between the finger and the slide, with room left that way.
46670
+ scrollRoomTowards(target, currentElement, axis, sign)) {
46671
+ return false;
46672
+ }
46673
+ const area = readArea(currentElement);
46674
+ // Where the slide being dragged stands: where the stage put it while a
46675
+ // travel is playing, its place on the map otherwise.
46676
+ const stage = stageRef.current;
46677
+ const basePlace = stage?.placeByArea.get(area) || placeOf.get(area) || {
46678
+ x: 0,
46679
+ y: 0
46680
+ };
46681
+ const baseOffset = {
46682
+ x: -basePlace.x * box.width,
46683
+ y: -basePlace.y * box.height
46684
+ };
46685
+ // Where the track IS: a travel grabbed mid-flight was already stopped
46686
+ // where the eye saw it, at the press — this only reads that place, so
46687
+ // the gesture starts from it rather than from where the map rests.
46688
+ const onScreen = caughtTravel ? caughtTravel.onScreenPx : baseOffset;
46689
+ caughtTravel = null;
46690
+ trackAnimationRef.current?.cancel();
46691
+ trackAnimationRef.current = null;
46692
+ progressAnimationRef.current?.cancel();
46693
+ progressAnimationRef.current = null;
46694
+ drag.axis = axis;
46695
+ drag.areaBack = areaBack;
46696
+ drag.areaOn = areaOn;
46697
+ drag.area = area;
46698
+ drag.box = box;
46699
+ drag.basePlace = basePlace;
46700
+ drag.baseOffset = baseOffset;
46701
+ const size = axis === "x" ? box.width : box.height;
46702
+ // Where the track was when the gesture took it over — nowhere, unless
46703
+ // it was travelling. Every pull is measured from it.
46704
+ const slack = axis === "x" ? onScreen.x - baseOffset.x : onScreen.y - baseOffset.y;
46705
+ drag.pull = {
46706
+ x: 0,
46707
+ y: 0,
46708
+ [axis]: slack
46709
+ };
46710
+ drag.progress = slack / size;
46711
+ stageDrag(drag);
46712
+ containerRef.current.toggleAttribute("data-slide-dragging", true);
46713
+ // From here the box is busy, whichever input asked: a wheel gesture and
46714
+ // a press must not both be moving the same track.
46715
+ dragRef.current = drag;
46716
+ return {
46717
+ size,
46718
+ slack,
46719
+ travelBack: Boolean(areaBack),
46720
+ travelOn: Boolean(areaOn)
46721
+ };
46722
+ },
46723
+ onPull: ({
46724
+ axis,
46725
+ pulled,
46726
+ progress
46727
+ }) => {
46728
+ drag.pull = {
46729
+ ...drag.pull,
46730
+ [axis]: pulled
46731
+ };
46732
+ drag.progress = progress;
46733
+ drag.areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
46734
+ paintDrag();
46735
+ },
46736
+ onEnd: ({
46737
+ axis,
46738
+ sign,
46739
+ travels,
46740
+ event
46741
+ }) => {
46742
+ dragRef.current = null;
46743
+ containerRef.current?.removeAttribute("data-slide-dragging");
46744
+ if (!travels) {
46745
+ returnToRest(drag);
46746
+ return;
46747
+ }
46748
+ // Where the slide is being left, for the travel to depart from instead
46749
+ // of from the map.
46750
+ travelFromRef.current = drag.offset;
46751
+ // …and where the indicator is being left, said about the slide that is
46752
+ // ARRIVING: the picture is `sign` of a box short of it, and that is
46753
+ // what the travel about to be drawn has to close.
46754
+ travelProgressFromRef.current = drag.progress - sign;
46755
+ const moved = axis === "x" ? move(-sign, 0, event) : move(0, -sign, event);
46756
+ if (!moved) {
46757
+ // Nowhere to go after all — a slide holding on to the user
46758
+ // (preventNav), or a caller that refused the change.
46759
+ travelFromRef.current = null;
46760
+ travelProgressFromRef.current = null;
46761
+ returnToRest(drag);
46762
+ return;
46763
+ }
46764
+ // A container whose `current` is held outside and was not moved:
46765
+ // nothing rendered, so nothing drew the travel and the track is still
46766
+ // under where the finger left it. One frame is all it takes to know.
46767
+ requestAnimationFrame(() => {
46768
+ if (travelFromRef.current) {
46769
+ travelFromRef.current = null;
46770
+ travelProgressFromRef.current = null;
46771
+ returnToRest(drag);
46772
+ }
46773
+ });
46774
+ },
46775
+ onGiveUp: () => {
46776
+ dragRef.current = null;
46777
+ // A press that never became a gesture: what it stopped goes on its way,
46778
+ // from where the finger caught it and over what is left of the travel.
46779
+ if (!caughtTravel) {
46780
+ return;
46781
+ }
46782
+ const {
46783
+ trackElement: trackCaught,
46784
+ offsetOnScreen,
46785
+ offsetTarget,
46786
+ onScreenPx: caughtOnScreenPx
46787
+ } = caughtTravel;
46788
+ caughtTravel = null;
46789
+ if (offsetOnScreen === offsetTarget) {
46790
+ settleTravel();
46791
+ return;
46792
+ }
46793
+ const durationMs = durationToMs(duration);
46794
+ // What is LEFT of it, at the pace it had: a travel caught nine tenths
46795
+ // of the way there and let go of does not start its duration again.
46796
+ const boxRect = trackCaught.getBoundingClientRect();
46797
+ const targetPx = offsetToPx(offsetTarget, boxRect);
46798
+ const leftToCover = Math.abs(drag.axis === "y" ? targetPx.y - caughtOnScreenPx.y : targetPx.x - caughtOnScreenPx.x);
46799
+ const size = drag.axis === "y" ? boxRect.height : boxRect.width;
46800
+ const travelRatio = size ? leftToCover / size : 1;
46801
+ trackCaught.style.setProperty("--slide-container-offset", offsetTarget);
46802
+ trackAnimationRef.current = trackCaught.animate([{
46803
+ translate: offsetOnScreen
46804
+ }, {
46805
+ translate: offsetTarget
46806
+ }], {
46807
+ duration: durationMs * travelRatio,
46808
+ easing: "ease-out"
46809
+ });
46810
+ trackAnimationRef.current.finished.then(settleTravel, () => {
46811
+ // taken over by a travel asked for since
46812
+ });
46813
+ }
46814
+ };
46815
+ };
46816
+
46817
+ // Whether a gesture may begin at all: one travel at a time, and a window
46818
+ // mid-roll has nothing to drag yet — it is on its way somewhere and the
46819
+ // content that goes with it has not moved (see goToArea).
46820
+ const canStartTravel = () => dragAxes && !dragRef.current && !rollingRef.current;
46821
+ const startDrag = pointerDownEvent => {
46822
+ if (!canStartTravel()) {
46823
+ return;
46824
+ }
46825
+ const caughtTravel = catchTravelInFlight();
46826
+ const caughtAxis = caughtTravel ? axisOfCaughtTravel(caughtTravel) : null;
46827
+ const handlers = createTravelHandlers(caughtTravel);
46828
+ const gesture = startDragToTravel(pointerDownEvent, {
46829
+ element: containerRef.current,
46830
+ axes: dragAxes,
46831
+ // Caught in flight: the hand is already in the gesture, so it is answered
46832
+ // from its first pixel rather than after a threshold it has no reason to
46833
+ // cross twice — on the axis what it caught is travelling on.
46834
+ immediate: caughtAxis && dragAxes.includes(caughtAxis) ? caughtAxis : false,
46835
+ ...handlers
46836
+ });
46837
+ if (!gesture) {
46838
+ return;
46839
+ }
46840
+ handlers.drag.gesture = gesture;
46841
+ dragRef.current = handlers.drag;
46842
+ };
46843
+
46844
+ // A wheel pushing the box sideways asks for a SLIDE, not for a place between
46845
+ // two: one push, one slide — the same thing an arrow key asks for, and it
46846
+ // plays at its own pace rather than under a hand (see watchWheelTravel).
46847
+ // Watched for the whole life of the box because such a gesture has no press
46848
+ // to start it: it begins with its first event.
46849
+ //
46850
+ // Reached through a ref and never rebuilt for it: a travel CHANGES which
46851
+ // slide is current, so a watcher listening on that would be torn down halfway
46852
+ // through the very gesture moving it.
46853
+ const travelOneStepRef = useRef(null);
46854
+ travelOneStepRef.current = ({
46855
+ axis,
46856
+ sign,
46857
+ event
46858
+ }) => {
46859
+ if (dragRef.current) {
46860
+ // A hand is holding the slides. They are its until it lets go.
46861
+ return;
46862
+ }
46863
+ if (axis === "x") {
46864
+ move(-sign, 0, event);
46865
+ } else {
46866
+ move(0, -sign, event);
46867
+ }
46868
+ };
46869
+ useLayoutEffect(() => {
46870
+ if (!scrollAxes) {
46871
+ return undefined;
46872
+ }
46873
+ return watchWheelTravel(containerRef.current, {
46874
+ axes: scrollAxes,
46875
+ onStep: detail => travelOneStepRef.current(detail)
46876
+ });
46877
+ }, [scrollAxes]);
46878
+
46879
+ // A gesture is listening on things that outlive this component.
46880
+ useLayoutEffect(() => {
46881
+ return () => {
46882
+ dragRef.current?.gesture?.stop();
46883
+ dragRef.current = null;
46884
+ progressAnimationRef.current?.cancel();
46885
+ };
46886
+ }, []);
46887
+
44570
46888
  // Arrows walk the map, Home/End jump to its ends — but only where those keys
44571
46889
  // mean nothing else: applyKeyboardShortcuts refuses to intercept a key the
44572
46890
  // focused element has a native use for, so an arrow inside a text field still
@@ -44593,27 +46911,27 @@ const SlideContainer = ({
44593
46911
  // `enabled` leaves the key to whatever else wants it (see
44594
46912
  // keyboard_shortcuts.js), rather than swallowing it here.
44595
46913
  arrowright: {
44596
- enabled: keyboardTravel,
46914
+ enabled: Boolean(keyboardAxes?.includes("x")),
44597
46915
  handler: e => travelled(move(1, 0, e))
44598
46916
  },
44599
46917
  arrowleft: {
44600
- enabled: keyboardTravel,
46918
+ enabled: Boolean(keyboardAxes?.includes("x")),
44601
46919
  handler: e => travelled(move(-1, 0, e))
44602
46920
  },
44603
46921
  arrowdown: {
44604
- enabled: keyboardTravel,
46922
+ enabled: Boolean(keyboardAxes?.includes("y")),
44605
46923
  handler: e => travelled(move(0, 1, e))
44606
46924
  },
44607
46925
  arrowup: {
44608
- enabled: keyboardTravel,
46926
+ enabled: Boolean(keyboardAxes?.includes("y")),
44609
46927
  handler: e => travelled(move(0, -1, e))
44610
46928
  },
44611
46929
  home: {
44612
- enabled: keyboardTravel,
46930
+ enabled: Boolean(keyboardAxes),
44613
46931
  handler: e => travelled(goToEnd(false, e))
44614
46932
  },
44615
46933
  end: {
44616
- enabled: keyboardTravel,
46934
+ enabled: Boolean(keyboardAxes),
44617
46935
  handler: e => travelled(goToEnd(true, e))
44618
46936
  }
44619
46937
  });
@@ -44626,6 +46944,22 @@ const SlideContainer = ({
44626
46944
  ref: containerRef,
44627
46945
  baseClassName: "navi_slide_container",
44628
46946
  "data-slide-container": ""
46947
+ // Which axes a touch may travel on, said in the DOM: what the browser
46948
+ // does with a finger is decided by CSS (touch-action) before any of this
46949
+ // has seen the gesture.
46950
+ ,
46951
+
46952
+ "data-travel-by-drag": dragAxes ?? undefined
46953
+ // The same fact, read by the shared gesture stylesheet: what scrolls
46954
+ // inside a box that travels must not spill onto the page behind it (see
46955
+ // drag_to_travel.js).
46956
+ ,
46957
+
46958
+ "data-drag-travel": travelAxes ?? undefined,
46959
+ onPointerDown: e => {
46960
+ startDrag(e);
46961
+ rest.onPointerDown?.(e);
46962
+ }
44629
46963
  // The focusable one, and a Tab stop: a slide is not (see Slide), so the
44630
46964
  // keyboard lands on what the current slide holds, and on this box when it
44631
46965
  // holds nothing. It is also what makes the arrows and Home/End reachable
@@ -47699,7 +50033,17 @@ const ListSelectable = props => {
47699
50033
  name: "select",
47700
50034
  prevented: () => e.preventDefault(),
47701
50035
  // tell the requester that we don't want to select this item
47702
- allowed: () => childController.setUIState(childController.value, e)
50036
+ // Asked of the item too, not only of the list: an item can be the one
50037
+ // refusing (the list already holds all it accepts, see maxLength), and
50038
+ // it is the one that then says why.
50039
+ allowed: () => {
50040
+ dispatchRequestInteraction(childController.ref.current, {
50041
+ event: e,
50042
+ name: "select",
50043
+ prevented: () => e.preventDefault(),
50044
+ allowed: () => childController.setUIState(childController.value, e)
50045
+ });
50046
+ }
47703
50047
  });
47704
50048
  },
47705
50049
  onnavi_request_unselect: e => {
@@ -48641,6 +50985,48 @@ const css$v = /* css */`
48641
50985
  display: none;
48642
50986
  }
48643
50987
  }
50988
+
50989
+ /* <List itemTransition>: the rows are named — a change the application wraps
50990
+ in a view transition is then seen row by row — and the pictures of the rows
50991
+ are drawn INSIDE the picture of the list, which is what lets the list's edge
50992
+ cut them.
50993
+
50994
+ Whether any of it happens is decided HERE and not in JS: rows that are named
50995
+ without being contained animate across the page (the pictures live in the
50996
+ top layer, where no overflow of the document reaches them), which is worse
50997
+ than not animating at all. So a browser with no nested groups gets no name
50998
+ either, and the change simply happens. */
50999
+ @supports (view-transition-group: contain) {
51000
+ .navi_list_container[data-item-transition] {
51001
+ /* The list needs a name to be a group at all; which name does not matter,
51002
+ only that no other element in the document carries it. */
51003
+ view-transition-name: match-element;
51004
+ view-transition-class: navi_list_transition;
51005
+ view-transition-group: contain;
51006
+
51007
+ /* A row is paired across the change by the id of the item it holds, never
51008
+ by the element that happens to hold it: rows are recycled as the list
51009
+ scrolls, and pairing on the element would pair the wrong two. */
51010
+ [data-view-transition-name] {
51011
+ view-transition-name: attr(
51012
+ data-view-transition-name type(<custom-ident>)
51013
+ );
51014
+ view-transition-class: navi_list_item;
51015
+ }
51016
+ }
51017
+ }
51018
+
51019
+ /* The list's edge, during the transition. */
51020
+ ::view-transition-group-children(.navi_list_transition) {
51021
+ overflow: clip;
51022
+ }
51023
+ /* The list box is the same thing before and after — only its rows moved — and
51024
+ a cross-fade of something onto itself is a flicker. */
51025
+ ::view-transition-old(.navi_list_transition),
51026
+ ::view-transition-new(.navi_list_transition) {
51027
+ mix-blend-mode: normal;
51028
+ animation: none;
51029
+ }
48644
51030
  `;
48645
51031
  const ListUI = props => {
48646
51032
  import.meta.css = [css$v, "@jsenv/navi/src/control/list/list.jsx"];
@@ -48843,6 +51229,7 @@ const ListUI = props => {
48843
51229
  ...rest,
48844
51230
  ref: ref,
48845
51231
  baseClassName: "navi_list_container",
51232
+ "data-item-transition": itemTransition ? "" : undefined,
48846
51233
  popover: popover,
48847
51234
  "data-horizontal": horizontal ? "" : undefined,
48848
51235
  "data-scroller": getScrollerAttribute(scroller),
@@ -48911,6 +51298,9 @@ const ListFirstResolver = props => {
48911
51298
  *
48912
51299
  * @type {import("ignore:preact").FunctionComponent<{
48913
51300
  * selectable?: boolean,
51301
+ * multiple?: boolean,
51302
+ * maxLength?: number,
51303
+ * maxLengthGuard?: number,
48914
51304
  * action?: (value: any) => void,
48915
51305
  * uiAction?: (value: any) => void,
48916
51306
  * popover?: boolean,
@@ -48944,6 +51334,22 @@ const ListFirstResolver = props => {
48944
51334
  * children?: import("ignore:preact").ComponentChildren,
48945
51335
  * [key: string]: any,
48946
51336
  * }>}
51337
+ * @param {boolean} [props.itemTransition]
51338
+ * Names each row, so a change the application wraps in
51339
+ * `document.startViewTransition` is seen row by row — rows moving to their new
51340
+ * place, an arriving row appearing where it lands — instead of the list
51341
+ * cross-fading as a block. The rows are drawn inside the list's own picture,
51342
+ * so one coming from outside the visible part of the list is cut at the list's
51343
+ * edge like any other overflow. Requires nested view transition groups
51344
+ * (Chrome/Edge 140+): elsewhere the rows are left unnamed and the change
51345
+ * simply happens — but the browser still names the document root, so the page
51346
+ * cross-fades as a whole unless the application says otherwise, which is its
51347
+ * call and not the list's:
51348
+ * ```css
51349
+ * @supports not (view-transition-group: contain) {
51350
+ * :root { view-transition-name: none; }
51351
+ * }
51352
+ * ```
48947
51353
  * @param {false|((index: number) => any)} [props.renderSkeleton]
48948
51354
  * What a row on its way looks like — a row of the shape the real ones will
48949
51355
  * have, so nothing moves when they arrive. Used for the rows a `<List.Items>`
@@ -48997,6 +51403,19 @@ const ListFirstResolver = props => {
48997
51403
  * scroll once it fills up is picked up then. When that is still not the box
48998
51404
  * you mean, say so: `"document"`, or the element itself (a ref works) —
48999
51405
  * nothing is guessed then.
51406
+ * @param {number} [props.maxLength]
51407
+ * How many items a `selectable multiple` list accepts — the same word, and
51408
+ * the same behaviour, as `maxLength` on a text field: a rule the list is
51409
+ * judged against, not a wall. A longer selection is allowed to exist and is
51410
+ * reported as invalid, which is what lets a value coming from elsewhere (an
51411
+ * API, a URL) be shown and then corrected.
51412
+ * @param {number} [props.maxLengthGuard]
51413
+ * The same limit, enforced as the selection is made: while the list holds as
51414
+ * many items as it accepts, the ones not selected go read-only — still
51415
+ * pointable, focusable and pressable, answering `"[max] max."` instead of
51416
+ * taking — and `uiAction` is not called. The selected ones stay takeable
51417
+ * back, so a selection that arrived too long can always be brought back
51418
+ * under the limit. Implies `maxLength` for validity.
49000
51419
  */
49001
51420
  const List = createComponentResolver([ListFirstResolver, ListSelectableResolver, ListUI]);
49002
51421
  const ListContent = ({
@@ -50700,11 +53119,9 @@ const ListItemReal = props => {
50700
53119
  // if any (there is no standalone highlight prop — see ListItem's own doc).
50701
53120
  useSearchHighlight(ref, matchInfo?.matchRanges, [children, hidden]);
50702
53121
  const columnsOverrideProps = useListItemColumnsOverrideProps(rest.style);
50703
- // <List itemTransition>: the row is named, so a change wrapped in a view
50704
- // transition animates it rather than cross-fading the list. Through the Box
50705
- // prop and not through style, because Box turns the name off again while the
50706
- // row is only partly visible (see usePartiallyHidden) — a row half-scrolled
50707
- // out of its container would otherwise animate from a clipped snapshot.
53122
+ // <List itemTransition>: the row carries the name it is to be paired by, and
53123
+ // the stylesheet turns it into a view-transition-name where a browser can
53124
+ // draw it inside the list (see the @supports block in the css above).
50708
53125
  const itemTransition = useContext(ItemTransitionContext);
50709
53126
 
50710
53127
  // Pressing a row that is busy or read-only must say why nothing happens,
@@ -50792,8 +53209,7 @@ const ListItemReal = props => {
50792
53209
  "aria-busy": loading ? "true" : undefined,
50793
53210
  "aria-readonly": readOnly ? "true" : undefined,
50794
53211
  "navi-error": error ? "" : undefined,
50795
- viewTransitionName: itemTransition ? `navi_list_item_${id}` : rest.viewTransitionName,
50796
- viewTransitionClass: itemTransition ? "navi_list_item" : rest.viewTransitionClass,
53212
+ "data-view-transition-name": itemTransition ? `navi_list_item_${id}` : undefined,
50797
53213
  onPointerDownCapture: blocked ? explainBlockedInteraction : undefined,
50798
53214
  onClickCapture: blocked ? event => {
50799
53215
  if (!pressStartedHereRef.current) {
@@ -51063,6 +53479,12 @@ const VISIBILITY_HIDDEN_STYLE = {
51063
53479
  * carries a `signal`, aborted when the list stops wanting those rows (the
51064
53480
  * window has moved on) — pass it to fetch to call the request off.
51065
53481
  *
53482
+ * A resource answers through its page reader:
53483
+ * `itemsAction={GAME.GET_PAGE.bindParams({ radar })}` — the rows are upserted
53484
+ * into the store on their way in, so the list draws store items rather than
53485
+ * copies of the JSON. The list holds the pages, the store holds the objects
53486
+ * (see docs/resource.md).
53487
+ *
51066
53488
  * A collection held in memory answers synchronously: `itemsAction={() => rows}`.
51067
53489
  * What it gives back is kept, so a collection that changes as a whole (a search
51068
53490
  * reordering it) is a different collection: give the run a `key` that changes
@@ -51594,6 +54016,9 @@ const useItemStore = ({
51594
54016
  };
51595
54017
  let result;
51596
54018
  try {
54019
+ if (typeof itemsAction !== "function") {
54020
+ throw new TypeError(`itemsAction must be a function, received ${itemsAction}. A resource feeds a list through its page reader: itemsAction={RESOURCE.GET_PAGE.bindParams(...)} — its other actions keep one response and cannot answer a range.`);
54021
+ }
51597
54022
  result = itemsAction(range);
51598
54023
  } catch (e) {
51599
54024
  failed(e);
@@ -54258,6 +56683,27 @@ const css$p = /* css */`
54258
56683
  }
54259
56684
  }
54260
56685
  `;
56686
+
56687
+ /**
56688
+ * @type {import("ignore:preact").FunctionComponent<{
56689
+ * maxLength?: number,
56690
+ * maxLengthGuard?: number,
56691
+ * [key: string]: any,
56692
+ * }>}
56693
+ * @param {number} [props.maxLength]
56694
+ * How many boxes the group accepts — the same word, and the same behaviour,
56695
+ * as `maxLength` on a text field: a rule the group is judged against, not a
56696
+ * wall. More checked boxes than that is allowed to exist and reported as
56697
+ * invalid, which is what lets a value coming from elsewhere be shown and then
56698
+ * corrected.
56699
+ * @param {number} [props.maxLengthGuard]
56700
+ * The same limit, enforced as the boxes are checked: while the group holds as
56701
+ * many as it accepts, the unchecked ones go read-only — still focusable and
56702
+ * pressable, answering `"[max] max."` instead of checking — and `uiAction` is
56703
+ * not called. The checked ones can always be unchecked, so a value that
56704
+ * arrived too long can always be brought back under the limit. Implies
56705
+ * `maxLength` for validity.
56706
+ */
54261
56707
  const CheckboxGroup = props => {
54262
56708
  const refDefault = useRef(null);
54263
56709
  props.ref = props.ref || refDefault;
@@ -57913,6 +60359,7 @@ function WheelUI(props) {
57913
60359
  })
57914
60360
  }), jsxs("div", {
57915
60361
  className: "navi_wheel_viewport",
60362
+ "data-no-drag-travel": "",
57916
60363
  children: [jsx("div", {
57917
60364
  className: "navi_wheel_pane",
57918
60365
  "data-side": "start"
@@ -58709,7 +61156,7 @@ const initDragTableColumnViaPointer = (pointerdownEvent, {
58709
61156
  onDrag,
58710
61157
  onRelease
58711
61158
  }) => {
58712
- dragAfterThreshold(pointerdownEvent, () => {
61159
+ dragAfterIntent(pointerdownEvent, () => {
58713
61160
  const [teardown, addTeardown] = createPubSub();
58714
61161
  const tableCell = pointerdownEvent.target.closest(".navi_table_cell");
58715
61162
  const table = tableCell.closest(".navi_table");
@@ -59174,6 +61621,7 @@ const TableColumnLeftResizeHandle = ({
59174
61621
  } = useContext(TableSizeContext);
59175
61622
  return jsx("div", {
59176
61623
  className: "navi_table_cell_resize_handle",
61624
+ "data-no-drag-travel": "",
59177
61625
  "data-left": "",
59178
61626
  onPointerDown: e => {
59179
61627
  if (e.button !== 0) {
@@ -59211,6 +61659,7 @@ const TableColumnRightResizeHandle = ({
59211
61659
  } = useContext(TableSizeContext);
59212
61660
  return jsx("div", {
59213
61661
  className: "navi_table_cell_resize_handle",
61662
+ "data-no-drag-travel": "",
59214
61663
  "data-right": "",
59215
61664
  onPointerDown: e => {
59216
61665
  if (e.button !== 0) {
@@ -59496,6 +61945,7 @@ const TableRowTopResizeHandle = ({
59496
61945
  } = useContext(TableSizeContext);
59497
61946
  return jsx("div", {
59498
61947
  className: "navi_table_cell_resize_handle",
61948
+ "data-no-drag-travel": "",
59499
61949
  "data-top": "",
59500
61950
  onPointerDown: e => {
59501
61951
  if (e.button !== 0) {
@@ -59533,6 +61983,7 @@ const TableRowBottomResizeHandle = ({
59533
61983
  } = useContext(TableSizeContext);
59534
61984
  return jsx("div", {
59535
61985
  className: "navi_table_cell_resize_handle",
61986
+ "data-no-drag-travel": "",
59536
61987
  "data-bottom": "",
59537
61988
  onPointerDown: e => {
59538
61989
  if (e.button !== 0) {
@@ -61457,7 +63908,14 @@ const TableCell = props => {
61457
63908
  "aria-selected": selected,
61458
63909
  "data-value": selectionValue,
61459
63910
  "data-editing": editing ? "" : undefined,
61460
- "data-grabbed": columnGrabbed ? "" : undefined,
63911
+ "data-grabbed": columnGrabbed ? "" : undefined
63912
+ // A column that can be dragged reads the pointer itself, so the gesture
63913
+ // is its own and a container travelling by drag must not take it (see
63914
+ // drag_to_travel.js). Said here rather than left to whoever puts a table in
63915
+ // a swipeable page: they cannot know, and this can.
63916
+ ,
63917
+
63918
+ "data-no-drag-travel": innerCanDragColumn ? "" : undefined,
61461
63919
  onClick: onClick,
61462
63920
  onPointerDown: e => {
61463
63921
  if (!innerCanDragColumn) {
@@ -64202,5 +66660,5 @@ const UserSvg = () => jsx("svg", {
64202
66660
  })
64203
66661
  });
64204
66662
 
64205
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
66663
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
64206
66664
  //# sourceMappingURL=jsenv_navi.js.map