@jsenv/navi 0.29.21 → 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
  }
@@ -32510,7 +32534,7 @@ const swapArrayItemByIndex = (array, indexA, indexB) => {
32510
32534
  */
32511
32535
  const ensureDocumentStartViewTransition = () => {
32512
32536
  if (!document.startViewTransition) {
32513
- document.startViewTransition = (updateCallback) => {
32537
+ const startViewTransitionPolyfill = (updateCallback) => {
32514
32538
  updateCallback();
32515
32539
  return {
32516
32540
  updateCallbackDone: Promise.resolve(),
@@ -32519,11 +32543,41 @@ const ensureDocumentStartViewTransition = () => {
32519
32543
  skipTransition: () => {},
32520
32544
  };
32521
32545
  };
32522
- }
32523
- 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
+ };
32524
32573
  };
32525
32574
 
32526
- const startViewTransition = (updateCallback) => {
32575
+ const startViewTransition$1 = (updateCallback) => {
32576
+ if (releaseHeldViewTransition) {
32577
+ const release = releaseHeldViewTransition;
32578
+ releaseHeldViewTransition = null;
32579
+ release();
32580
+ }
32527
32581
  const viewTransition = document.startViewTransition(updateCallback);
32528
32582
  viewTransition.updateCallbackDone.catch(ignoreSkip);
32529
32583
  viewTransition.ready.catch(ignoreSkip);
@@ -33722,6 +33776,29 @@ const updateDocumentState = (value) => {
33722
33776
  documentStateSignal.value = value;
33723
33777
  };
33724
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
+
33725
33802
  const setupBrowserIntegrationViaHistory = ({
33726
33803
  applyActions,
33727
33804
  applyRouting,
@@ -33773,6 +33850,19 @@ const setupBrowserIntegrationViaHistory = ({
33773
33850
 
33774
33851
  let abortController = null;
33775
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) => {
33776
33866
  const isSameUrl = url === window.location.href;
33777
33867
  const {
33778
33868
  reason,
@@ -34342,6 +34432,30 @@ const Head = ({
34342
34432
  * ```
34343
34433
  */
34344
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
+ };
34345
34459
  const debug$1 = (...args) => {
34346
34460
  {
34347
34461
  return;
@@ -34362,9 +34476,51 @@ const Route = props => {
34362
34476
  ...props
34363
34477
  });
34364
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
+
34365
34520
  // RouteContainer: traverses children statically per render, finds the active branch,
34366
34521
  // and renders only that branch — or the fallback if nothing matches.
34367
- // 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.
34368
34524
  const RouteContainer = ({
34369
34525
  id,
34370
34526
  element,
@@ -34374,8 +34530,24 @@ const RouteContainer = ({
34374
34530
  const {
34375
34531
  activeBranch
34376
34532
  } = collectBranches(children);
34377
- debug$1(`[container "${id}"] RENDER, active=${activeBranch ? activeBranch.type : "none"}`);
34378
- 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;
34379
34551
  if (!content) {
34380
34552
  return null;
34381
34553
  }
@@ -34496,6 +34668,1219 @@ const RouteUI = ({
34496
34668
  return element;
34497
34669
  };
34498
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
+
34499
35884
  const routeAction = (
34500
35885
  routeOrRoutes,
34501
35886
  action,
@@ -36456,6 +37841,13 @@ const useIsVisited = (url) => {
36456
37841
  */
36457
37842
  const BinderItemContext = createContext(null);
36458
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
+
36459
37851
  /*
36460
37852
  * Custom hook to apply semi-transparent color when an element should be dimmed.
36461
37853
  *
@@ -36754,15 +38146,15 @@ installImportMetaCssBuild(import.meta);const css$O = /* css */`
36754
38146
  margin-left: -0.1em;
36755
38147
  }
36756
38148
 
36757
- &[data-appearance="text"] {
38149
+ &[data-variant="text"] {
36758
38150
  --link-color: unset;
36759
38151
  --link-text-decoration: none;
36760
38152
  }
36761
- &[data-appearance="icon"] {
38153
+ &[data-variant="icon"] {
36762
38154
  --link-color: unset;
36763
38155
  --link-text-decoration: none;
36764
38156
  }
36765
- &[data-appearance="tab"] {
38157
+ &[data-variant="tab"] {
36766
38158
  --link-background-hover: color-mix(
36767
38159
  in srgb,
36768
38160
  var(--link-background, transparent),
@@ -36899,15 +38291,15 @@ Object.assign(PSEUDO_CLASSES, {
36899
38291
  * (`navi_value`); defaults to `href`.
36900
38292
  * @param {boolean} [props.current] - Forces the "current" state on (otherwise
36901
38293
  * derived from the href/route).
36902
- * @param {"text"|"icon"|"tab"} [props.appearance] - Visual variant
36903
- * (`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,
36904
38296
  * `"tab"` renders a tab-like affordance.
36905
38297
  * @param {boolean|"top"|"bottom"|"left"|"right"} [props.currentIndicator] - A
36906
38298
  * bar drawn on the given edge (or bottom when `true`) while current.
36907
38299
  * @param {boolean} [props.currentEffectBold] - Bold the text while current
36908
38300
  * (reserving the bold width so layout doesn't shift).
36909
38301
  * @param {boolean} [props.currentEffectShadow] - Inset-shadow effect while
36910
- * current (used with `appearance="tab"`).
38302
+ * current (used with `variant="tab"`).
36911
38303
  * @param {boolean|import("ignore:preact").ComponentChild} [props.startIcon] - Icon
36912
38304
  * placed before the text.
36913
38305
  * @param {boolean|import("ignore:preact").ComponentChild} [props.endIcon] - Icon
@@ -36976,7 +38368,7 @@ const LinkPlain = props => {
36976
38368
  anchor,
36977
38369
  value = href,
36978
38370
  // visual
36979
- appearance,
38371
+ variant,
36980
38372
  current,
36981
38373
  currentIndicator,
36982
38374
  currentEffectBold,
@@ -36993,6 +38385,7 @@ const LinkPlain = props => {
36993
38385
  props.id = href.slice(1);
36994
38386
  }
36995
38387
  const selectionContext = useContext(SelectionContext);
38388
+ const nav = useContext(NavContext);
36996
38389
  const visited = useIsVisited(href);
36997
38390
  const {
36998
38391
  selection,
@@ -37093,8 +38486,19 @@ const LinkPlain = props => {
37093
38486
  const innerChildren = children || (hrefFallback ? href : children);
37094
38487
  const startIconEl = startIcon;
37095
38488
  const endIconEl = innerEndIcon;
37096
- const currentIndicatorPosition = currentIndicator === true ? "bottom" : currentIndicator;
37097
- 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;
37098
38502
  const {
37099
38503
  onClick,
37100
38504
  preventDefault
@@ -37104,9 +38508,24 @@ const LinkPlain = props => {
37104
38508
  color: anchor && !innerChildren ? "inherit" : undefined,
37105
38509
  ...controlRootProps,
37106
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.
37107
38516
  preventDefault: undefined,
37108
38517
  anchor: undefined,
37109
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,
37110
38529
  onClick: e => {
37111
38530
  onClick?.(e);
37112
38531
  if (preventDefault) {
@@ -37135,7 +38554,7 @@ const LinkPlain = props => {
37135
38554
  // Visual
37136
38555
  ,
37137
38556
 
37138
- "data-appearance": appearance,
38557
+ "data-variant": variant,
37139
38558
  "data-current-effect-bold": currentEffectBold ? "" : undefined,
37140
38559
  "data-current-effect-shadow": currentEffectShadow ? "" : undefined,
37141
38560
  "data-current-indicator-position": currentIndicatorPosition,
@@ -37156,20 +38575,23 @@ const LinkPlain = props => {
37156
38575
  children: [startIconEl, innerChildren, endIconEl]
37157
38576
  });
37158
38577
  };
37159
- const LinkCurrentIndicator = () => {
38578
+ const LinkCurrentIndicator = ({
38579
+ viewTransitionName
38580
+ }) => {
37160
38581
  return jsx("span", {
37161
- className: "navi_current_indicator"
38582
+ className: "navi_current_indicator",
38583
+ style: viewTransitionName ? {
38584
+ viewTransitionName
38585
+ } : undefined
37162
38586
  });
37163
38587
  };
37164
38588
  markAsOutsideTextFlow(LinkCurrentIndicator);
37165
38589
 
37166
- const NavContext = createContext();
37167
- createContext();
37168
-
37169
38590
  installImportMetaCssBuild(import.meta);/**
37170
38591
  * TabList component with support for horizontal and vertical layouts
37171
38592
  * https://dribbble.com/search/tabs
37172
38593
  */
38594
+ let navCount = 0;
37173
38595
  const css$N = /* css */`
37174
38596
  @layer navi {
37175
38597
  .navi_nav {
@@ -37334,6 +38756,22 @@ const NavStyleCSSVars = {
37334
38756
  paddingLeft: "--nav-padding-left",
37335
38757
  background: "--nav-background"
37336
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
+ */
37337
38775
  const Nav = ({
37338
38776
  children,
37339
38777
  spacing,
@@ -37341,11 +38779,23 @@ const Nav = ({
37341
38779
  expand,
37342
38780
  expandX,
37343
38781
  linkBorderRadiusInherit,
38782
+ currentIndicator,
38783
+ currentIndicatorSlides = true,
37344
38784
  panelPosition,
37345
38785
  // "before" or "after": which side the panel sits on, turning the nav into folder tabs
37346
38786
  ...props
37347
38787
  }) => {
37348
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]);
37349
38799
  children = toChildArray(children);
37350
38800
  return jsx(Box, {
37351
38801
  as: "nav",
@@ -37362,7 +38812,7 @@ const Nav = ({
37362
38812
  ...props,
37363
38813
  styleCSSVars: NavStyleCSSVars,
37364
38814
  children: jsx(NavContext.Provider, {
37365
- value: true,
38815
+ value: navContextValue,
37366
38816
  children: children
37367
38817
  })
37368
38818
  });
@@ -39725,8 +41175,8 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39725
41175
  --color-mix: var(--color-mix-light);
39726
41176
  }
39727
41177
 
39728
- /* Checkbox appearance */
39729
- &[data-appearance="checkbox"] {
41178
+ /* Checkbox variant */
41179
+ &[data-variant="checkbox"] {
39730
41180
  .navi_checkbox_marker {
39731
41181
  width: 100%;
39732
41182
  height: 100%;
@@ -39756,8 +41206,8 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39756
41206
  }
39757
41207
  }
39758
41208
 
39759
- /* Switch appearance */
39760
- &[data-appearance="switch"] {
41209
+ /* Switch variant */
41210
+ &[data-variant="switch"] {
39761
41211
  --switch-outer-width: calc(var(--switch-width) + var(--switch-padding));
39762
41212
  --margin: var(--switch-margin);
39763
41213
  --width: var(--switch-outer-width);
@@ -39789,7 +41239,7 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39789
41239
  border-color: transparent;
39790
41240
  }
39791
41241
 
39792
- &[data-appearance="icon"] {
41242
+ &[data-variant="icon"] {
39793
41243
  --margin: 0;
39794
41244
  --width: auto;
39795
41245
  --height: auto;
@@ -39798,7 +41248,7 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39798
41248
  border: none;
39799
41249
  }
39800
41250
 
39801
- &[data-appearance="button"] {
41251
+ &[data-variant="button"] {
39802
41252
  --margin: 0;
39803
41253
  --width: auto;
39804
41254
  --height: auto;
@@ -39875,7 +41325,7 @@ const InputCheckboxFieldInterface = props => {
39875
41325
  const {
39876
41326
  icon,
39877
41327
  switch: switchProp,
39878
- appearance = icon ? "icon" : switchProp ? "switch" : "checkbox",
41328
+ variant = icon ? "icon" : switchProp ? "switch" : "checkbox",
39879
41329
  // "checkbox", "switch", "icon", "button"
39880
41330
  accentColor
39881
41331
  } = props;
@@ -39889,13 +41339,13 @@ const InputCheckboxFieldInterface = props => {
39889
41339
  elementSelector: ".navi_checkbox_accent_probe"
39890
41340
  });
39891
41341
  let visualVnode;
39892
- if (appearance === "icon" || icon) {
41342
+ if (variant === "icon" || icon) {
39893
41343
  visualVnode = jsx("div", {
39894
41344
  className: "navi_checkbox_icon",
39895
41345
  "aria-hidden": "true",
39896
41346
  children: Array.isArray(icon) ? icon[checked ? 1 : 0] : icon
39897
41347
  });
39898
- } else if (appearance === "switch") {
41348
+ } else if (variant === "switch") {
39899
41349
  visualVnode = jsx(SwitchUI, {});
39900
41350
  } else {
39901
41351
  visualVnode = jsx(Box, {
@@ -39916,16 +41366,16 @@ const InputCheckboxFieldInterface = props => {
39916
41366
  // (passsing any custom width/height would auto disable aspectRatio forced by the square prop)
39917
41367
  ,
39918
41368
 
39919
- square: appearance === "button" ? true : undefined,
41369
+ square: variant === "button" ? true : undefined,
39920
41370
  ...checkboxRootProps,
39921
41371
  ref: boxRef,
39922
- appearance: undefined,
41372
+ variant: undefined,
39923
41373
  switch: undefined,
39924
41374
  icon: undefined,
39925
- "data-appearance": appearance,
41375
+ "data-variant": variant,
39926
41376
  baseClassName: "navi_checkbox",
39927
41377
  pseudoStateSelector: ".navi_control_input",
39928
- styleCSSVars: appearance === "switch" ? CheckboxSwitchStyleCSSVars : appearance === "button" ? CheckboxButtonStyleCSSVars : CheckboxStyleCSSVars,
41378
+ styleCSSVars: variant === "switch" ? CheckboxSwitchStyleCSSVars : variant === "button" ? CheckboxButtonStyleCSSVars : CheckboxStyleCSSVars,
39929
41379
  basePseudoState: basePseudoState,
39930
41380
  pseudoClasses: CheckboxPseudoClasses,
39931
41381
  pseudoElements: CheckboxPseudoElements,
@@ -40500,8 +41950,8 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
40500
41950
  }
40501
41951
  }
40502
41952
 
40503
- /* Radio appearance */
40504
- &[data-appearance="radio"] {
41953
+ /* Radio variant */
41954
+ &[data-variant="radio"] {
40505
41955
  display: inline-flex;
40506
41956
  align-items: center;
40507
41957
  justify-content: center;
@@ -40548,16 +41998,16 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
40548
41998
  }
40549
41999
  }
40550
42000
 
40551
- /* Icon appearance */
40552
- &[data-appearance="icon"] {
42001
+ /* Icon variant */
42002
+ &[data-variant="icon"] {
40553
42003
  --width: auto;
40554
42004
  --height: auto;
40555
42005
  --outline-offset: 2px;
40556
42006
  --outline-width: 2px;
40557
42007
  }
40558
42008
 
40559
- /* Button appearance */
40560
- &[data-appearance="button"] {
42009
+ /* Button variant */
42010
+ &[data-variant="button"] {
40561
42011
  --margin: 0;
40562
42012
  --outline-offset: 0px;
40563
42013
  --width: auto;
@@ -40662,18 +42112,18 @@ const InputRadioHeadless = props => {
40662
42112
  ...radioHostProps
40663
42113
  });
40664
42114
  };
40665
- const APPEARANCE_SET = new Set(["icon", "button", "radio"]);
42115
+ const VARIANT_SET = new Set(["icon", "button", "radio"]);
40666
42116
  const InputRadioFieldInterface = props => {
40667
42117
  import.meta.css = [css$E, "@jsenv/navi/src/control/input/input_radio.jsx"];
40668
42118
  const [radioRootProps, radioHostProps] = useCheckableProps(props);
40669
42119
  const {
40670
42120
  icon,
40671
- appearance
42121
+ variant
40672
42122
  } = props;
40673
- let appearanceResolved = appearance || (icon ? "icon" : "radio");
40674
- if (appearance && !APPEARANCE_SET.has(appearance)) {
40675
- console.warn(`InputRadio: unsupported appearance "${appearance}". Falling back to "radio".`);
40676
- 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";
40677
42127
  }
40678
42128
  const {
40679
42129
  basePseudoState,
@@ -40685,10 +42135,10 @@ const InputRadioFieldInterface = props => {
40685
42135
  elementSelector: ".navi_radio_accent_probe"
40686
42136
  });
40687
42137
  let visualVNode;
40688
- if (appearanceResolved === "icon" || icon) {
42138
+ if (variantResolved === "icon" || icon) {
40689
42139
  visualVNode = Array.isArray(icon) ? icon[checked ? 1 : 0] : icon;
40690
42140
  } else {
40691
- // appearanceResolved === "radio"
42141
+ // variantResolved === "radio"
40692
42142
  visualVNode = jsx(RadioSvg, {});
40693
42143
  }
40694
42144
  return jsxs(Box, {
@@ -40697,16 +42147,16 @@ const InputRadioFieldInterface = props => {
40697
42147
  // (passsing any custom width/height would auto disable aspectRatio forced by the square prop)
40698
42148
  ,
40699
42149
 
40700
- square: appearanceResolved === "button" ? true : undefined,
42150
+ square: variantResolved === "button" ? true : undefined,
40701
42151
  ...radioRootProps,
40702
42152
  ref: boxRef,
40703
42153
  icon: undefined,
40704
- appearance: undefined,
40705
- "data-appearance": appearanceResolved,
42154
+ variant: undefined,
42155
+ "data-variant": variantResolved,
40706
42156
  baseClassName: "navi_radio",
40707
42157
  pseudoStateSelector: ".navi_control_input",
40708
42158
  basePseudoState: basePseudoState,
40709
- styleCSSVars: appearanceResolved === "button" ? RadioButtonStyleCSSVars : RadioStyleCSSVars,
42159
+ styleCSSVars: variantResolved === "button" ? RadioButtonStyleCSSVars : RadioStyleCSSVars,
40710
42160
  pseudoClasses: RadioPseudoClasses,
40711
42161
  pseudoElements: RadioPseudoElements,
40712
42162
  children: [jsx("span", {
@@ -43687,7 +45137,9 @@ installImportMetaCssBuild(import.meta);/**
43687
45137
  * carries on to the one being pulled in or puts the current one back — the
43688
45138
  * gesture decides, not the distance alone. It walks ONE AXIS, chosen from the
43689
45139
  * first few pixels: a diagonal would ask for two travels at once and only one
43690
- * slide can arrive.
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.
43691
45143
  *
43692
45144
  * The slides live INSIDE the box, which is what makes this work for a popup: a
43693
45145
  * dialog and a popover are both promoted to the browser's top layer, so no
@@ -43696,6 +45148,18 @@ installImportMetaCssBuild(import.meta);/**
43696
45148
  * it is the same component in the document, in a dialog or in a popover.
43697
45149
  */
43698
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
+
43699
45163
  /* Every slide in the same grid cell: the box then measures itself on the
43700
45164
  LARGEST of them, in both directions, without anything being measured by
43701
45165
  hand — which is also why nothing here resizes as the slides change. Each
@@ -43854,6 +45318,19 @@ const ratioOfOneTravel = (track, from, to, targetBefore) => {
43854
45318
  // A translate ("-100% 0%", "-260px 0px", "none") as two numbers of pixels.
43855
45319
  // Percentages are the size of the box, which is what a translate resolves them
43856
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
+ };
43857
45334
  const offsetToPx = (offset, box) => {
43858
45335
  if (!offset || offset === "none") {
43859
45336
  return {
@@ -43888,62 +45365,29 @@ const hurryTravel = animation => {
43888
45365
  animation.playbackRate = rate > HURRY_RATE_MAX ? HURRY_RATE_MAX : rate;
43889
45366
  };
43890
45367
 
43891
- // How far a pointer goes before it is a travel rather than a click: below this
43892
- // a press that wandered a pixel is still a press, and the slides do not budge.
43893
- const DRAG_START_THRESHOLD = 10;
43894
- // How much of a box has to be pulled for letting go to carry on rather than put
43895
- // the slide back. Under half, because a gesture that has clearly begun is an
43896
- // intention: asking for the slide to be dragged all the way across turns a
43897
- // travel into work.
43898
- const DRAG_COMMIT_RATIO = 0.3;
43899
- // A flick travels whatever the distance: the hand said "away" quickly, which is
43900
- // the whole gesture — px/ms of pointer, and a few pixels to tell it from a tap
43901
- // that shook.
43902
- const DRAG_FLICK_VELOCITY = 0.4;
43903
- const DRAG_FLICK_DISTANCE = 8;
43904
- // Pulling towards nothing: the track follows at a fraction of the finger, so
43905
- // the gesture is answered (something moves) while saying there is nothing that
43906
- // way. Let go and it comes back — a wall one can lean on, never walk through.
43907
- const DRAG_RESISTANCE = 0.3;
43908
- // What a drag must not start on: something that reads the pointer itself. A
43909
- // button or a link is not in the list — dragging from one travels, and the
43910
- // click it would have made is swallowed on the way out (see onDragEnd).
43911
- const DRAG_EXCLUDED_SELECTOR = ["input", "textarea", "select", '[contenteditable=""]', '[contenteditable="true"]', "[data-no-slide-drag]"].join(",");
43912
-
43913
- // A scroller between the pointer and the slide it is in, with room left the way
43914
- // the gesture goes: it gets the gesture, and the slides stay where they are —
43915
- // dragging a row that scrolls sideways scrolls that row, and only a row with
43916
- // nowhere left to go hands the travel over.
43917
- const scrollRoomTowards = (fromElement, stopElement, axis, sign) => {
43918
- let element = fromElement;
43919
- while (element && element !== stopElement && element.nodeType === 1) {
43920
- const size = axis === "x" ? element.clientWidth : element.clientHeight;
43921
- const scrollSize = axis === "x" ? element.scrollWidth : element.scrollHeight;
43922
- if (scrollSize > size + 1) {
43923
- const {
43924
- overflowX,
43925
- overflowY
43926
- } = getComputedStyle(element);
43927
- const overflow = axis === "x" ? overflowX : overflowY;
43928
- if (overflow === "auto" || overflow === "scroll") {
43929
- const position = axis === "x" ? element.scrollLeft : element.scrollTop;
43930
- // Dragging the content one way reveals what is on the other side of
43931
- // it: to the right means going back up the scroll.
43932
- const room = sign > 0 ? position : scrollSize - size - position;
43933
- if (room > 1) {
43934
- return true;
43935
- }
43936
- }
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;
43937
45382
  }
43938
- element = element.parentElement;
43939
45383
  }
43940
- return false;
45384
+ return allowed || null;
43941
45385
  };
43942
45386
 
43943
45387
  // Which axes the map has anything on, read from the layout alone: it is what
43944
45388
  // says which way a touch may travel, and a touch is answered before any of the
43945
45389
  // DOM below has been looked at.
43946
- const dragAxesOf = layout => {
45390
+ const travelAxesOf = layout => {
43947
45391
  if (typeof layout === "string") {
43948
45392
  return layout === "column" ? "y" : "x";
43949
45393
  }
@@ -44052,6 +45496,26 @@ const durationToMs = duration => {
44052
45496
  }
44053
45497
  return String(duration).trimEnd().endsWith("ms") ? number : number * 1000;
44054
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
+ };
44055
45519
  const readArea = slideElement => slideElement.getAttribute("data-slide-area") || slideElement.id || "";
44056
45520
 
44057
45521
  /**
@@ -44079,7 +45543,21 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
44079
45543
  * it says where one starts, not where one is — say `current` for that.
44080
45544
  * Without it the first slide is the one shown, the way a stack of pages opens
44081
45545
  * on its first page.
44082
- * @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.
44083
45561
  * @param {boolean} [props.loop] - the slides are a window over something
44084
45562
  * endless (days, months, a carousel) rather than places one stays at. A
44085
45563
  * travel plays as usual and then the window comes back to the slide it rests
@@ -44092,17 +45570,28 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
44092
45570
  * one that just travelled there. Called once the travel is over, and in the
44093
45571
  * same render as the return to rest — anything later shows the old content
44094
45572
  * for a frame.
44095
- * @param {boolean} [props.travelByKeyboard=true] - whether the arrows (and
44096
- * Home/End) walk the map. On by default: a map one can see is a map one
44097
- * expects to walk. Off when the arrows mean something else where these slides
44098
- * are a list of choices one moves through, a picker whose screens are
44099
- * steps rather than places so the keys keep the meaning the content gives
44100
- * them, and travelling stays something one asks for (a button, a command).
44101
- * @param {boolean} [props.travelByDrag=true] - whether a pointer dragging the
44102
- * slides travels. On by default: slides side by side are something one
44103
- * expects to push around with a thumb. Off where the gesture belongs to the
44104
- * content (a canvas one draws on, a map one pans), or where the slides are
44105
- * steps of a form rather than a row one browses.
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.
44106
45595
  * @param {string} [props.duration="300ms"] - how long a slide change takes.
44107
45596
  */
44108
45597
  const SlideContainer = ({
@@ -44110,10 +45599,12 @@ const SlideContainer = ({
44110
45599
  current: currentProp,
44111
45600
  defaultCurrent,
44112
45601
  onCurrentChange,
45602
+ commit = "now",
44113
45603
  loop,
44114
45604
  onLoop,
44115
45605
  travelByKeyboard = true,
44116
45606
  travelByDrag = true,
45607
+ travelByScroll = "x",
44117
45608
  duration = "300ms",
44118
45609
  children,
44119
45610
  ...rest
@@ -44127,6 +45618,15 @@ const SlideContainer = ({
44127
45618
  // The AREA of the slide being shown, not its rank: a rank would be wrong the
44128
45619
  // moment a slide appears before it, and there is nothing to renumber here.
44129
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);
44130
45630
  // Where the window is while it rolls, and nothing more: a looping container
44131
45631
  // rests where it rested before (below), so this is the travel itself rather
44132
45632
  // than a change of slide.
@@ -44180,9 +45680,21 @@ const SlideContainer = ({
44180
45680
  // track rests: a slide let go of halfway carries on from under the finger.
44181
45681
  // Read and dropped by the layout effect, which is the one drawing it.
44182
45682
  const travelFromRef = useRef(null);
44183
- const current = rollingArea ?? currentProp ?? currentAreaState;
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;
44184
45689
  const vertical = layout === "column";
44185
- const dragAxes = useMemo(() => travelByDrag ? dragAxesOf(layout) : null, [travelByDrag, layout]);
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;
44186
45698
  // Which required slides have been answered (see Slide's own `required`). Held
44187
45699
  // here rather than in each slide because answering one says something about
44188
45700
  // the others: the steps after it were answered about a state that has just
@@ -44201,6 +45713,19 @@ const SlideContainer = ({
44201
45713
  // one before it wrote, not what the last render saw.
44202
45714
  const cameFromRef = useRef({});
44203
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
+
44204
45729
  // The travel is given back as soon as the picture it must not animate has
44205
45730
  // been painted: one frame with it off is all it takes.
44206
45731
  useLayoutEffect(() => {
@@ -44297,6 +45822,9 @@ const SlideContainer = ({
44297
45822
  }
44298
45823
  stageRef.current = null;
44299
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);
44300
45828
  for (const slideElement of slideElements) {
44301
45829
  const {
44302
45830
  x,
@@ -44320,6 +45848,17 @@ const SlideContainer = ({
44320
45848
  const offset = `${-x * 100}% ${-y * 100}%`;
44321
45849
  offsetRef.current = offset;
44322
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
+ }
44323
45862
  };
44324
45863
 
44325
45864
  // Everything positional is decided here, from the DOM, once per render: where
@@ -44352,6 +45891,9 @@ const SlideContainer = ({
44352
45891
  stageRef.current = null;
44353
45892
  }
44354
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;
44355
45897
  const drawnArea = stage ? stage.area : drawnAreaRef.current;
44356
45898
  const travelStarts = !noTravel && durationMs > 0 && drawnArea !== undefined && drawnArea !== currentArea && slideElements.some(slideElement => readArea(slideElement) === drawnArea);
44357
45899
  if (travelStarts) {
@@ -44363,6 +45905,7 @@ const SlideContainer = ({
44363
45905
  x: Math.sign(realPlaceOf(currentArea).x - realPlaceOf(drawnArea).x),
44364
45906
  y: Math.sign(realPlaceOf(currentArea).y - realPlaceOf(drawnArea).y)
44365
45907
  };
45908
+ travelStep = step;
44366
45909
  // Kept, not replaced: the slides a chain of quick presses has already
44367
45910
  // left behind are still trailing off screen, and taking them off stage
44368
45911
  // now would blink them out mid-travel.
@@ -44477,6 +46020,11 @@ const SlideContainer = ({
44477
46020
  duration: durationMs * travelRatio,
44478
46021
  easing
44479
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);
44480
46028
  // Presses still waiting behind this one: it is already late, so it is
44481
46029
  // sent home at once rather than played out at the pace of someone who
44482
46030
  // has stopped pressing. Someone pressing → four times is asking to be
@@ -44667,11 +46215,62 @@ const SlideContainer = ({
44667
46215
  };
44668
46216
  return true;
44669
46217
  }
46218
+ const leftArea = readArea(currentElement);
44670
46219
  setCurrentAreaState(area);
44671
- 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);
44672
46248
  return true;
44673
46249
  };
44674
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
+
44675
46274
  // The press kept during a roll, taken once the window rests and the travel is
44676
46275
  // given back (noTravel off): by direction when there was one, so it is read
44677
46276
  // against the map as it is NOW — the content moved one step under the window
@@ -44842,6 +46441,59 @@ const SlideContainer = ({
44842
46441
  const y = drag.baseOffset.y + drag.pull.y;
44843
46442
  drag.offset = `${x}px ${y}px`;
44844
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
+ });
44845
46497
  };
44846
46498
 
44847
46499
  // The two slides the gesture can bring in, placed one box either side of the
@@ -44907,9 +46559,11 @@ const SlideContainer = ({
44907
46559
  trackAnimationRef.current = null;
44908
46560
  track.style.setProperty("--slide-container-offset", restOffset);
44909
46561
  if (!durationMs || !pulled) {
46562
+ paintTravelProgress(0);
44910
46563
  settleTravel();
44911
46564
  return;
44912
46565
  }
46566
+ animateTravelProgress(drag.progress, durationMs * (pulled / size), "ease-out");
44913
46567
  const animation = track.animate([{
44914
46568
  translate: drag.offset
44915
46569
  }, {
@@ -44923,265 +46577,311 @@ const SlideContainer = ({
44923
46577
  // cancelled by a travel asked for since — that one carries the stage on
44924
46578
  });
44925
46579
  };
44926
- const onDragMove = pointerMoveEvent => {
44927
- const drag = dragRef.current;
44928
- if (!drag || pointerMoveEvent.pointerId !== drag.pointerId) {
44929
- return;
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;
44930
46595
  }
44931
- if (!drag.axis) {
44932
- const reachX = Math.abs(pointerMoveEvent.clientX - drag.startX);
44933
- const reachY = Math.abs(pointerMoveEvent.clientY - drag.startY);
44934
- if (reachX < DRAG_START_THRESHOLD && reachY < DRAG_START_THRESHOLD) {
44935
- return;
44936
- }
44937
- // ONE axis, decided by the first few pixels and never revisited: a
44938
- // diagonal gesture would ask for two travels at once and only one slide
44939
- // can arrive — so the finger picks the axis it leans on, and the slides
44940
- // walk that one alone.
44941
- const axis = reachX >= reachY ? "x" : "y";
44942
- const sign = axis === "x" ? Math.sign(pointerMoveEvent.clientX - drag.startX) : Math.sign(pointerMoveEvent.clientY - drag.startY);
44943
- const areaBack = axis === "x" ? areaTowards(-1, 0) : areaTowards(0, -1);
44944
- const areaOn = axis === "x" ? areaTowards(1, 0) : areaTowards(0, 1);
44945
- // Everything positional is read HERE rather than when the pointer landed:
44946
- // the travel that was playing then may have arrived since, and it is what
44947
- // the slides are doing at the moment the gesture takes them over that the
44948
- // gesture must carry on from.
44949
- const track = trackRef.current;
44950
- const {
44951
- slideElements,
44952
- placeOf
44953
- } = readMap();
44954
- const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
44955
- const box = track.getBoundingClientRect();
44956
- if (!areaBack && !areaOn || !currentElement || !box.width || !box.height || scrollRoomTowards(drag.target, currentElement, axis, sign)) {
44957
- // Nothing that way, or something else with a better claim on the
44958
- // gesture: given up rather than half-taken, so whatever else wants it
44959
- // (a scroller, the page) gets it whole.
44960
- drag.stop();
44961
- dragRef.current = null;
44962
- return;
44963
- }
44964
- const area = readArea(currentElement);
44965
- // Where the slide being dragged stands: where the stage put it while a
44966
- // travel is playing, its place on the map otherwise.
44967
- const stage = stageRef.current;
44968
- const basePlace = stage?.placeByArea.get(area) || placeOf.get(area) || {
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: {
44969
46640
  x: 0,
44970
46641
  y: 0
44971
- };
44972
- const baseOffset = {
44973
- x: -basePlace.x * box.width,
44974
- y: -basePlace.y * box.height
44975
- };
44976
- // Where the track IS, taken over from whatever was playing: a travel
44977
- // grabbed mid-flight carries on from under the finger, so the animation
44978
- // is dropped and its position kept.
44979
- const onScreen = trackAnimationRef.current?.playState === "running" ? offsetToPx(getComputedStyle(track).translate, box) : baseOffset;
44980
- trackAnimationRef.current?.cancel();
44981
- trackAnimationRef.current = null;
44982
- drag.axis = axis;
44983
- drag.areaBack = areaBack;
44984
- drag.areaOn = areaOn;
44985
- drag.area = area;
44986
- drag.box = box;
44987
- drag.basePlace = basePlace;
44988
- drag.baseOffset = baseOffset;
44989
- // Where the track was when the gesture took it over — nowhere, unless it
44990
- // was travelling. Every pull is measured from it.
44991
- drag.slack = {
44992
- x: onScreen.x - baseOffset.x,
44993
- y: onScreen.y - baseOffset.y
44994
- };
44995
- drag.pull = {
44996
- ...drag.slack
44997
- };
44998
- // The pixels spent deciding are not pulled back: the slide starts moving
44999
- // from where the finger is now, so it follows it exactly rather than
45000
- // jumping the threshold it just crossed.
45001
- drag.startX = pointerMoveEvent.clientX;
45002
- drag.startY = pointerMoveEvent.clientY;
45003
- stageDrag(drag);
45004
- drag.lastPosition = axis === "x" ? pointerMoveEvent.clientX : pointerMoveEvent.clientY;
45005
- drag.lastTime = pointerMoveEvent.timeStamp;
45006
- containerRef.current.toggleAttribute("data-slide-dragging", true);
45007
- // Every move from here on, wherever the finger wanders — off the box, off
45008
- // the window — and the release with it.
45009
- containerRef.current.setPointerCapture(drag.pointerId);
45010
- }
45011
- const {
45012
- axis
45013
- } = drag;
45014
- const size = axis === "x" ? drag.box.width : drag.box.height;
45015
- const moved = axis === "x" ? pointerMoveEvent.clientX - drag.startX : pointerMoveEvent.clientY - drag.startY;
45016
- // Measured from where the gesture took the track over, never from what the
45017
- // move before it painted: the resistance below would otherwise be applied
45018
- // again to a value it has already shrunk, and a finger going nowhere would
45019
- // see the slide creep back on its own.
45020
- let pulled = drag.slack[axis] + moved;
45021
- // Which slide is being pulled in: dragging the track to the right brings in
45022
- // the one on the left, which is the one BEFORE it.
45023
- const areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
45024
- if (!areaPulled) {
45025
- pulled *= DRAG_RESISTANCE;
45026
- }
45027
- if (pulled > size) {
45028
- pulled = size;
45029
- } else if (pulled < -size) {
45030
- pulled = -size;
45031
- }
45032
- drag.pull = {
45033
- ...drag.pull,
45034
- [axis]: pulled
45035
- };
45036
- paintDrag();
45037
- // How fast the hand is going, so that letting go says something a distance
45038
- // cannot: a short flick travels, a long slow drag put back does not.
45039
- const position = axis === "x" ? pointerMoveEvent.clientX : pointerMoveEvent.clientY;
45040
- const elapsed = pointerMoveEvent.timeStamp - drag.lastTime;
45041
- if (elapsed > 0) {
45042
- const instant = (position - drag.lastPosition) / elapsed;
45043
- drag.velocity = drag.velocity * 0.4 + instant * 0.6;
45044
- }
45045
- drag.lastPosition = position;
45046
- drag.lastTime = pointerMoveEvent.timeStamp;
45047
- };
45048
- const onDragEnd = pointerEvent => {
45049
- const drag = dragRef.current;
45050
- if (!drag || pointerEvent.pointerId !== drag.pointerId) {
45051
- return;
45052
- }
45053
- drag.stop();
45054
- dragRef.current = null;
45055
- containerRef.current?.removeAttribute("data-slide-dragging");
45056
- if (!drag.axis) {
45057
- // A press that never became a gesture: nothing was staged, nothing moved.
45058
- return;
45059
- }
45060
- // The click the browser makes of a press that travelled: swallowed, or
45061
- // letting go over a button would press it. Capture, so it never reaches
45062
- // what it landed on, and dropped right after in case none comes.
45063
- const swallowClick = clickEvent => {
45064
- clickEvent.stopPropagation();
45065
- clickEvent.preventDefault();
46642
+ },
46643
+ progress: 0,
46644
+ offset: null,
46645
+ gesture: null
45066
46646
  };
45067
- document.addEventListener("click", swallowClick, {
45068
- capture: true
45069
- });
45070
- setTimeout(() => {
45071
- document.removeEventListener("click", swallowClick, {
45072
- capture: true
45073
- });
45074
- });
45075
- const {
45076
- axis
45077
- } = drag;
45078
- const size = axis === "x" ? drag.box.width : drag.box.height;
45079
- const pulled = drag.pull[axis];
45080
- const sign = pulled > 0 ? 1 : -1;
45081
- const areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
45082
- // A hand that stopped before letting go has said "here", whatever it was
45083
- // doing a moment earlier — so the speed only counts while it is still going.
45084
- const velocity = pointerEvent.timeStamp - drag.lastTime > 100 ? 0 : drag.velocity;
45085
- const flicked = Math.abs(velocity) > DRAG_FLICK_VELOCITY && Math.sign(velocity) === sign && Math.abs(pulled) > DRAG_FLICK_DISTANCE;
45086
- const travels =
45087
- // A gesture taken away rather than let go of (the browser scrolling
45088
- // something else, a call coming in) said nothing: the slide goes back.
45089
- pointerEvent.type !== "pointercancel" && areaPulled && (flicked || Math.abs(pulled) > size * DRAG_COMMIT_RATIO);
45090
- if (!travels) {
45091
- returnToRest(drag);
45092
- return;
45093
- }
45094
- // Where the slide is being left, for the travel to depart from instead of
45095
- // from the map.
45096
- travelFromRef.current = drag.offset;
45097
- const moved = axis === "x" ? move(-sign, 0, pointerEvent) : move(0, -sign, pointerEvent);
45098
- if (!moved) {
45099
- // Nowhere to go after all — a slide holding on to the user (preventNav).
45100
- travelFromRef.current = null;
45101
- returnToRest(drag);
45102
- return;
45103
- }
45104
- // A container whose `current` is held outside and was not moved: nothing
45105
- // rendered, so nothing drew the travel and the track is still under where
45106
- // the finger left it. One frame is all it takes to know.
45107
- requestAnimationFrame(() => {
45108
- if (travelFromRef.current) {
45109
- travelFromRef.current = null;
45110
- returnToRest(drag);
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
+ });
45111
46813
  }
45112
- });
46814
+ };
45113
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;
45114
46821
  const startDrag = pointerDownEvent => {
45115
- if (!travelByDrag || dragRef.current || pointerDownEvent.button !== 0) {
46822
+ if (!canStartTravel()) {
45116
46823
  return;
45117
46824
  }
45118
- // A window mid-roll has nothing to drag yet: it is on its way somewhere and
45119
- // the content that goes with it has not moved (see goToArea).
45120
- if (rollingRef.current) {
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) {
45121
46838
  return;
45122
46839
  }
45123
- const target = pointerDownEvent.target;
45124
- if (!target.closest || target.closest(DRAG_EXCLUDED_SELECTOR)) {
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.
45125
46861
  return;
45126
46862
  }
45127
- const containerEl = containerRef.current;
45128
- const onMove = pointerMoveEvent => {
45129
- onDragMove(pointerMoveEvent);
45130
- };
45131
- const onEnd = pointerEvent => {
45132
- onDragEnd(pointerEvent);
45133
- };
45134
- // The browser's own drag, which a mouse starts on a link or an image after
45135
- // a few pixels: it would take the pointer away mid-gesture and leave the
45136
- // slides hanging.
45137
- const preventNativeDrag = dragStartEvent => {
45138
- dragStartEvent.preventDefault();
45139
- };
45140
- const stop = () => {
45141
- containerEl.removeEventListener("pointermove", onMove);
45142
- containerEl.removeEventListener("dragstart", preventNativeDrag);
45143
- window.removeEventListener("pointerup", onEnd);
45144
- window.removeEventListener("pointercancel", onEnd);
45145
- };
45146
- dragRef.current = {
45147
- pointerId: pointerDownEvent.pointerId,
45148
- target,
45149
- startX: pointerDownEvent.clientX,
45150
- startY: pointerDownEvent.clientY,
45151
- // Nothing but a press so far: the axis, the slides either side of the one
45152
- // being dragged and where they all stand are read the moment the finger
45153
- // says which way it is going (see onDragMove).
45154
- axis: null,
45155
- areaBack: null,
45156
- areaOn: null,
45157
- slack: {
45158
- x: 0,
45159
- y: 0
45160
- },
45161
- pull: {
45162
- x: 0,
45163
- y: 0
45164
- },
45165
- offset: null,
45166
- velocity: 0,
45167
- lastPosition: 0,
45168
- lastTime: pointerDownEvent.timeStamp,
45169
- stop
45170
- };
45171
- containerEl.addEventListener("pointermove", onMove);
45172
- containerEl.addEventListener("dragstart", preventNativeDrag);
45173
- // On the window, not on the box: a pointer released outside it (or taken
45174
- // away by the browser) must still end the gesture, or the slides would stay
45175
- // where the finger left them.
45176
- window.addEventListener("pointerup", onEnd);
45177
- window.addEventListener("pointercancel", onEnd);
46863
+ if (axis === "x") {
46864
+ move(-sign, 0, event);
46865
+ } else {
46866
+ move(0, -sign, event);
46867
+ }
45178
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]);
45179
46878
 
45180
46879
  // A gesture is listening on things that outlive this component.
45181
46880
  useLayoutEffect(() => {
45182
46881
  return () => {
45183
- dragRef.current?.stop();
46882
+ dragRef.current?.gesture?.stop();
45184
46883
  dragRef.current = null;
46884
+ progressAnimationRef.current?.cancel();
45185
46885
  };
45186
46886
  }, []);
45187
46887
 
@@ -45211,27 +46911,27 @@ const SlideContainer = ({
45211
46911
  // `enabled` leaves the key to whatever else wants it (see
45212
46912
  // keyboard_shortcuts.js), rather than swallowing it here.
45213
46913
  arrowright: {
45214
- enabled: travelByKeyboard,
46914
+ enabled: Boolean(keyboardAxes?.includes("x")),
45215
46915
  handler: e => travelled(move(1, 0, e))
45216
46916
  },
45217
46917
  arrowleft: {
45218
- enabled: travelByKeyboard,
46918
+ enabled: Boolean(keyboardAxes?.includes("x")),
45219
46919
  handler: e => travelled(move(-1, 0, e))
45220
46920
  },
45221
46921
  arrowdown: {
45222
- enabled: travelByKeyboard,
46922
+ enabled: Boolean(keyboardAxes?.includes("y")),
45223
46923
  handler: e => travelled(move(0, 1, e))
45224
46924
  },
45225
46925
  arrowup: {
45226
- enabled: travelByKeyboard,
46926
+ enabled: Boolean(keyboardAxes?.includes("y")),
45227
46927
  handler: e => travelled(move(0, -1, e))
45228
46928
  },
45229
46929
  home: {
45230
- enabled: travelByKeyboard,
46930
+ enabled: Boolean(keyboardAxes),
45231
46931
  handler: e => travelled(goToEnd(false, e))
45232
46932
  },
45233
46933
  end: {
45234
- enabled: travelByKeyboard,
46934
+ enabled: Boolean(keyboardAxes),
45235
46935
  handler: e => travelled(goToEnd(true, e))
45236
46936
  }
45237
46937
  });
@@ -45249,7 +46949,13 @@ const SlideContainer = ({
45249
46949
  // has seen the gesture.
45250
46950
  ,
45251
46951
 
45252
- "data-travel-by-drag": dragAxes ?? undefined,
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,
45253
46959
  onPointerDown: e => {
45254
46960
  startDrag(e);
45255
46961
  rest.onPointerDown?.(e);
@@ -48327,7 +50033,17 @@ const ListSelectable = props => {
48327
50033
  name: "select",
48328
50034
  prevented: () => e.preventDefault(),
48329
50035
  // tell the requester that we don't want to select this item
48330
- 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
+ }
48331
50047
  });
48332
50048
  },
48333
50049
  onnavi_request_unselect: e => {
@@ -49269,6 +50985,48 @@ const css$v = /* css */`
49269
50985
  display: none;
49270
50986
  }
49271
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
+ }
49272
51030
  `;
49273
51031
  const ListUI = props => {
49274
51032
  import.meta.css = [css$v, "@jsenv/navi/src/control/list/list.jsx"];
@@ -49471,6 +51229,7 @@ const ListUI = props => {
49471
51229
  ...rest,
49472
51230
  ref: ref,
49473
51231
  baseClassName: "navi_list_container",
51232
+ "data-item-transition": itemTransition ? "" : undefined,
49474
51233
  popover: popover,
49475
51234
  "data-horizontal": horizontal ? "" : undefined,
49476
51235
  "data-scroller": getScrollerAttribute(scroller),
@@ -49539,6 +51298,9 @@ const ListFirstResolver = props => {
49539
51298
  *
49540
51299
  * @type {import("ignore:preact").FunctionComponent<{
49541
51300
  * selectable?: boolean,
51301
+ * multiple?: boolean,
51302
+ * maxLength?: number,
51303
+ * maxLengthGuard?: number,
49542
51304
  * action?: (value: any) => void,
49543
51305
  * uiAction?: (value: any) => void,
49544
51306
  * popover?: boolean,
@@ -49572,6 +51334,22 @@ const ListFirstResolver = props => {
49572
51334
  * children?: import("ignore:preact").ComponentChildren,
49573
51335
  * [key: string]: any,
49574
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
+ * ```
49575
51353
  * @param {false|((index: number) => any)} [props.renderSkeleton]
49576
51354
  * What a row on its way looks like — a row of the shape the real ones will
49577
51355
  * have, so nothing moves when they arrive. Used for the rows a `<List.Items>`
@@ -49625,6 +51403,19 @@ const ListFirstResolver = props => {
49625
51403
  * scroll once it fills up is picked up then. When that is still not the box
49626
51404
  * you mean, say so: `"document"`, or the element itself (a ref works) —
49627
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.
49628
51419
  */
49629
51420
  const List = createComponentResolver([ListFirstResolver, ListSelectableResolver, ListUI]);
49630
51421
  const ListContent = ({
@@ -51328,11 +53119,9 @@ const ListItemReal = props => {
51328
53119
  // if any (there is no standalone highlight prop — see ListItem's own doc).
51329
53120
  useSearchHighlight(ref, matchInfo?.matchRanges, [children, hidden]);
51330
53121
  const columnsOverrideProps = useListItemColumnsOverrideProps(rest.style);
51331
- // <List itemTransition>: the row is named, so a change wrapped in a view
51332
- // transition animates it rather than cross-fading the list. Through the Box
51333
- // prop and not through style, because Box turns the name off again while the
51334
- // row is only partly visible (see usePartiallyHidden) — a row half-scrolled
51335
- // 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).
51336
53125
  const itemTransition = useContext(ItemTransitionContext);
51337
53126
 
51338
53127
  // Pressing a row that is busy or read-only must say why nothing happens,
@@ -51420,8 +53209,7 @@ const ListItemReal = props => {
51420
53209
  "aria-busy": loading ? "true" : undefined,
51421
53210
  "aria-readonly": readOnly ? "true" : undefined,
51422
53211
  "navi-error": error ? "" : undefined,
51423
- viewTransitionName: itemTransition ? `navi_list_item_${id}` : rest.viewTransitionName,
51424
- viewTransitionClass: itemTransition ? "navi_list_item" : rest.viewTransitionClass,
53212
+ "data-view-transition-name": itemTransition ? `navi_list_item_${id}` : undefined,
51425
53213
  onPointerDownCapture: blocked ? explainBlockedInteraction : undefined,
51426
53214
  onClickCapture: blocked ? event => {
51427
53215
  if (!pressStartedHereRef.current) {
@@ -54895,6 +56683,27 @@ const css$p = /* css */`
54895
56683
  }
54896
56684
  }
54897
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
+ */
54898
56707
  const CheckboxGroup = props => {
54899
56708
  const refDefault = useRef(null);
54900
56709
  props.ref = props.ref || refDefault;
@@ -58550,6 +60359,7 @@ function WheelUI(props) {
58550
60359
  })
58551
60360
  }), jsxs("div", {
58552
60361
  className: "navi_wheel_viewport",
60362
+ "data-no-drag-travel": "",
58553
60363
  children: [jsx("div", {
58554
60364
  className: "navi_wheel_pane",
58555
60365
  "data-side": "start"
@@ -59346,7 +61156,7 @@ const initDragTableColumnViaPointer = (pointerdownEvent, {
59346
61156
  onDrag,
59347
61157
  onRelease
59348
61158
  }) => {
59349
- dragAfterThreshold(pointerdownEvent, () => {
61159
+ dragAfterIntent(pointerdownEvent, () => {
59350
61160
  const [teardown, addTeardown] = createPubSub();
59351
61161
  const tableCell = pointerdownEvent.target.closest(".navi_table_cell");
59352
61162
  const table = tableCell.closest(".navi_table");
@@ -59811,6 +61621,7 @@ const TableColumnLeftResizeHandle = ({
59811
61621
  } = useContext(TableSizeContext);
59812
61622
  return jsx("div", {
59813
61623
  className: "navi_table_cell_resize_handle",
61624
+ "data-no-drag-travel": "",
59814
61625
  "data-left": "",
59815
61626
  onPointerDown: e => {
59816
61627
  if (e.button !== 0) {
@@ -59848,6 +61659,7 @@ const TableColumnRightResizeHandle = ({
59848
61659
  } = useContext(TableSizeContext);
59849
61660
  return jsx("div", {
59850
61661
  className: "navi_table_cell_resize_handle",
61662
+ "data-no-drag-travel": "",
59851
61663
  "data-right": "",
59852
61664
  onPointerDown: e => {
59853
61665
  if (e.button !== 0) {
@@ -60133,6 +61945,7 @@ const TableRowTopResizeHandle = ({
60133
61945
  } = useContext(TableSizeContext);
60134
61946
  return jsx("div", {
60135
61947
  className: "navi_table_cell_resize_handle",
61948
+ "data-no-drag-travel": "",
60136
61949
  "data-top": "",
60137
61950
  onPointerDown: e => {
60138
61951
  if (e.button !== 0) {
@@ -60170,6 +61983,7 @@ const TableRowBottomResizeHandle = ({
60170
61983
  } = useContext(TableSizeContext);
60171
61984
  return jsx("div", {
60172
61985
  className: "navi_table_cell_resize_handle",
61986
+ "data-no-drag-travel": "",
60173
61987
  "data-bottom": "",
60174
61988
  onPointerDown: e => {
60175
61989
  if (e.button !== 0) {
@@ -62094,7 +63908,14 @@ const TableCell = props => {
62094
63908
  "aria-selected": selected,
62095
63909
  "data-value": selectionValue,
62096
63910
  "data-editing": editing ? "" : undefined,
62097
- "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,
62098
63919
  onClick: onClick,
62099
63920
  onPointerDown: e => {
62100
63921
  if (!innerCanDragColumn) {
@@ -64839,5 +66660,5 @@ const UserSvg = () => jsx("svg", {
64839
66660
  })
64840
66661
  });
64841
66662
 
64842
- 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 };
64843
66664
  //# sourceMappingURL=jsenv_navi.js.map