@jsenv/navi 0.29.25 → 0.29.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import { createContext, isValidElement, h, Fragment, toChildArray, render, optio
7
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, 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";
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, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, 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";
@@ -34654,17 +34654,25 @@ const Route = props => {
34654
34654
  });
34655
34655
  };
34656
34656
  /**
34657
- * The routes a tree of <Route> children is made of, in the order they are
34657
+ * The pages a tree of <Route> children is made of, in the order they are
34658
34658
  * written. Reading them is what turns a router into a row one can walk: "one
34659
34659
  * step that way" is a fact about the order the branches were declared in, and
34660
34660
  * nothing in a URL says it.
34661
34661
  *
34662
+ * A page is `{ route, params }`, never the route alone: a section of a page is
34663
+ * as often a PARAM as it is a route of its own — `<Route route={PAGE}
34664
+ * routeParams={{ section: "done" }}>` is how this very file selects a branch on
34665
+ * one — and three branches of the same route are then the same object three
34666
+ * times. Told apart by their params, they are three pages one walks between;
34667
+ * told apart by identity, they are one page and there is nowhere to walk.
34668
+ * `params` is undefined for a branch that is a route on its own.
34669
+ *
34662
34670
  * The same walk the container does to find the active branch (collectBranches),
34663
34671
  * except that it keeps every leaf rather than the one that matches — and reads
34664
34672
  * no signal, so asking does not subscribe the asker to anything.
34665
34673
  */
34666
- const collectRoutes = children => {
34667
- const routes = [];
34674
+ const collectRoutePages = children => {
34675
+ const pages = [];
34668
34676
  const visit = child => {
34669
34677
  if (!child || child === true || child === false) {
34670
34678
  return;
@@ -34680,18 +34688,22 @@ const collectRoutes = children => {
34680
34688
  }
34681
34689
  const {
34682
34690
  children: nodeChildren,
34683
- route
34691
+ route,
34692
+ routeParams
34684
34693
  } = child.props;
34685
34694
  if (nodeChildren) {
34686
34695
  visit(nodeChildren);
34687
34696
  return;
34688
34697
  }
34689
34698
  if (route) {
34690
- routes.push(route);
34699
+ pages.push({
34700
+ route,
34701
+ params: routeParams
34702
+ });
34691
34703
  }
34692
34704
  };
34693
34705
  visit(children);
34694
- return routes;
34706
+ return pages;
34695
34707
  };
34696
34708
 
34697
34709
  // RouteContainer: traverses children statically per render, finds the active branch,
@@ -34897,14 +34909,19 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
34897
34909
  // transition carries was measured once, at the start, against a destination
34898
34910
  // this travel is no longer going to.
34899
34911
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
34912
+ // The name the box wears while it travels, and only then (see nameForTravel).
34913
+ const TRAVEL_NAME = "navi-route-travel";
34900
34914
  const css$R = /* css */`
34915
+ /* The name that makes the page inside this box a picture of its own during a
34916
+ transition — rather than part of the one big picture the document takes, so
34917
+ the two pages can move past each other while everything else stays where it
34918
+ is — is not written here: it is worn only for the length of a travel (see
34919
+ nameForTravel). A name belongs to ONE element at a time, and a page can hold
34920
+ several of these boxes at once — a section of the url and a search param of
34921
+ the root route are two rows of tabs, both live, and only one of them is ever
34922
+ travelling. */
34901
34923
  .navi_route_travel {
34902
34924
  position: relative;
34903
- /* Named, so the page inside this box is a picture of its own during a
34904
- transition rather than part of the one big picture the document takes:
34905
- the two pages can then move past each other while everything else stays
34906
- where it is. */
34907
- view-transition-name: navi-route-travel;
34908
34925
  /* The gesture takes the axis the pages travel on and leaves the other one
34909
34926
  to the page, so a list still scrolls under the same finger. */
34910
34927
  touch-action: pan-y;
@@ -35102,21 +35119,30 @@ const css$R = /* css */`
35102
35119
 
35103
35120
  /**
35104
35121
  * @type {import("ignore:preact").FunctionComponent<{
35105
- * routes?: Array<object>,
35122
+ * routes?: Array<object|{route: object, params?: object}>,
35106
35123
  * axis?: "x"|"y",
35107
35124
  * travelByDrag?: boolean,
35108
- * onTravel?: (detail: {route: object, cause: string}) => void|Promise<void>,
35125
+ * onTravel?: (detail: {route: object, params: object|undefined, cause: string}) => void|Promise<void>,
35109
35126
  * }>}
35110
- * @param {Array<object>} [props.routes] - the tabs, in the order they are shown.
35111
- * Read from the <Route> children by default, in the order they are written:
35112
- * the router already holds that list, and asking a caller to write it twice is
35113
- * asking for the two to disagree. Pass it to say another order, or when the
35114
- * pages are not children of this box.
35127
+ * @param {Array<object|{route: object, params?: object}>} [props.routes] - the
35128
+ * tabs, in the order they are shown. Read from the <Route> children by
35129
+ * default, in the order they are written: the router already holds that list,
35130
+ * and asking a caller to write it twice is asking for the two to disagree.
35131
+ * Pass it to say another order, when the pages are not children of this box,
35132
+ * or to name a tab the children cannot — the section a <Route fallback> shows
35133
+ * is a tab like the others, and only its params say which one.
35134
+ *
35135
+ * An entry is a route, or `{ route, params }` when the tabs of the row are a
35136
+ * PARAM of one route rather than routes of their own (the form
35137
+ * `<Route routeParams>` selects a branch on, and the form that lets a link
35138
+ * with no params reopen the section one was looking at). Written as bare
35139
+ * routes, three tabs of one route are the same object three times: there is
35140
+ * then one tab, and nowhere to travel.
35115
35141
  * @param {"x"|"y"} [props.axis="x"] - which way the pages are laid out.
35116
35142
  * @param {boolean} [props.travelByDrag=true] - whether a pointer dragging the
35117
35143
  * page travels. Off where the gesture belongs to the content.
35118
- * @param {(detail: {route: object, cause: "drag"|"wheel"|"revert"}) => void|Promise<void>} [props.onTravel]
35119
- * - how to go to a route. The default REPLACES the current history entry
35144
+ * @param {(detail: {route: object, params: object|undefined, cause: "drag"|"wheel"|"revert"}) => void|Promise<void>} [props.onTravel]
35145
+ * - how to go to a tab. The default REPLACES the current history entry
35120
35146
  * rather than pushing one: a swipe is how one browses a page, not a place one
35121
35147
  * aimed at, and three swipes back and forth must not bury the way out of the
35122
35148
  * page under six entries. A tab pressed is the other case and pushes, which
@@ -35139,8 +35165,9 @@ const RouteTravel = ({
35139
35165
  axis = "x",
35140
35166
  travelByDrag = true,
35141
35167
  onTravel = ({
35142
- route
35143
- }) => route.redirectTo(),
35168
+ route,
35169
+ params
35170
+ }) => route.redirectTo(params),
35144
35171
  className,
35145
35172
  children,
35146
35173
  ...rest
@@ -35152,51 +35179,58 @@ const RouteTravel = ({
35152
35179
  // left, the animations the finger drives, and what to do with them once the
35153
35180
  // browser has them ready. Null when no page is on its way anywhere.
35154
35181
  const travelRef = useRef(null);
35155
- // The route this box has ASKED for and is still waiting to see arrive.
35182
+ // The page this box has ASKED for and is still waiting to see arrive.
35156
35183
  // Routing is asynchronous: a travel's own navigation lands well after the
35157
35184
  // travel decided anything about it — sometimes after the travel was undone —
35158
- // and read back as "the route changed" it would start a second travel nobody
35185
+ // and read back as "the page changed" it would start a second travel nobody
35159
35186
  // asked for, over pictures that are already showing something else.
35160
- const routeAskedForRef = useRef(null);
35187
+ const pageAskedForRef = useRef(null);
35161
35188
  // What a press stopped in flight, until the gesture says what it is about.
35162
35189
  const caughtAtPressRef = useRef(null);
35163
35190
  // The latest way to answer a gesture, for a watcher that outlives every
35164
35191
  // render (see the wheel effect below).
35165
35192
  const travelHandlersRef = useRef(null);
35166
35193
  const pointerDownRef = useRef(null);
35167
- const routesFromChildren = useMemo(() => collectRoutes(children), [children]);
35168
- const routes = routesProp || routesFromChildren;
35169
-
35170
- // Which page is on screen, read from the routes themselves: every one of them
35171
- // is read, so this re-renders when any of them starts or stops matching.
35172
- let currentIndex = -1;
35173
- for (let i = 0; i < routes.length; i++) {
35174
- if (routes[i].matchingSignal.value) {
35175
- currentIndex = i;
35176
- }
35177
- }
35194
+ const pagesFromChildren = useMemo(() => collectRoutePages(children), [children]);
35195
+ const pagesFromProp = useMemo(() => routesProp && routesProp.map(normalizePage), [routesProp]);
35196
+ const pages = pagesFromProp || pagesFromChildren;
35197
+
35198
+ // Which page is on screen, read from the pages themselves: every one of them
35199
+ // is read, so this re-renders when any of them starts or stops matching — and
35200
+ // for a row whose tabs are params of one route, when the params move from one
35201
+ // tab to the next (see pageIsCurrent).
35202
+ const currentIndex = currentPageIndex(pages);
35178
35203
  // The page that was on screen when the change now happening was asked for:
35179
35204
  // a travel is between two of them, and by the time anything renders the first
35180
35205
  // one is already gone. Written after each render (below), so a subscriber
35181
35206
  // reading it — they all run before Preact flushes — reads the one being left.
35182
35207
  const currentIndexRef = useRef(currentIndex);
35183
35208
 
35209
+ // Where a page is asked for, whoever asks: a page is a route AND the params
35210
+ // that say which of its tabs, and a caller told only the route would send the
35211
+ // row back to whichever tab the URL already says (see redirectTo).
35212
+ const travelTo = (page, cause) => onTravel({
35213
+ route: page.route,
35214
+ params: page.params,
35215
+ cause
35216
+ });
35217
+
35184
35218
  // One travel, whoever asked for it: a finger, a tab pressed, the browser's
35185
35219
  // own back button. What differs is only who moves it — the finger drives it
35186
35220
  // frame by frame (`scrub`), everything else lets it play.
35187
35221
  const beginTravel = ({
35188
- route,
35189
- fromRoute,
35222
+ page,
35223
+ fromPage,
35190
35224
  direction,
35191
35225
  scrub,
35192
35226
  change
35193
35227
  }) => {
35194
35228
  const travel = {
35195
- route,
35229
+ page,
35196
35230
  // The page this set off from, kept rather than looked up again: the URL
35197
35231
  // changes at the first pixel, so a moment later nothing on screen
35198
35232
  // remembers where it started.
35199
- fromRoute,
35233
+ fromPage,
35200
35234
  direction,
35201
35235
  scrub,
35202
35236
  ratio: 0,
@@ -35207,12 +35241,16 @@ const RouteTravel = ({
35207
35241
  ended: false
35208
35242
  };
35209
35243
  travelRef.current = travel;
35244
+ // Taken before the picture is: the browser reads the name off the DOM as it
35245
+ // stands when the transition starts, and this box is only a picture of its
35246
+ // own for as long as it is the one travelling.
35247
+ nameForTravel(elementRef.current);
35210
35248
  document.documentElement.setAttribute(TRAVEL_ATTRIBUTE, direction);
35211
35249
  if (scrub) {
35212
35250
  holdPictures(travel);
35213
35251
  document.documentElement.setAttribute(DRAGGED_ATTRIBUTE, "");
35214
35252
  }
35215
- routeAskedForRef.current = route;
35253
+ pageAskedForRef.current = page;
35216
35254
  // The box as it stands before anything moves: rendering is held, so this is
35217
35255
  // still the page being left (see holdTravelHeight).
35218
35256
  const heightBefore = elementRef.current.getBoundingClientRect().height;
@@ -35223,7 +35261,7 @@ const RouteTravel = ({
35223
35261
  // The picture the browser is about to take must be of the page that was
35224
35262
  // asked for, and a route matching is not yet a page rendered.
35225
35263
  const viewTransition = startViewTransition(async () => {
35226
- await whileRouteRenders(route, async () => {
35264
+ await whilePageRenders(page, async () => {
35227
35265
  releaseRendering();
35228
35266
  if (change) {
35229
35267
  await change();
@@ -35276,21 +35314,41 @@ const RouteTravel = ({
35276
35314
  };
35277
35315
 
35278
35316
  // A page change nobody here asked for: a tab pressed, a key, the back button.
35279
- // The transition is started from the route's own announcement rather than
35280
- // from a render, because a render is one flush too late — by then the DOM
35281
- // holds the new page and the picture of the old one cannot be taken anymore.
35317
+ // The transition is started from what the router SAYS rather than from a
35318
+ // render, because a render is one flush too late — by then the DOM holds the
35319
+ // new page and the picture of the old one cannot be taken anymore.
35320
+ //
35321
+ // Watched as a position in the row rather than route by route. A route
35322
+ // announces its own status, and the row's tabs can all be one route: the
35323
+ // announcement then says a section changed without saying which is on screen,
35324
+ // and it says it about things this row does not move for (a route that has
35325
+ // now been visited, a param of its own that is not a tab of this row). Worse,
35326
+ // a status is published from inside the routing and the params it carries are
35327
+ // the ones known at that instant — a section that lands as a signal settles
35328
+ // is announced late, or not at all. The signals ARE the position, so the
35329
+ // position is read from them: one computed over the whole row, notified once
35330
+ // per move, whichever route moved and whether by matching or by params.
35282
35331
  useLayoutEffect(() => {
35283
- const unsubscribes = routes.map((route, index) => route.subscribeStatus(({
35284
- matching
35285
- }) => {
35286
- if (!matching) {
35332
+ const currentIndexSignal = computed(() => currentPageIndex(pages));
35333
+ const onRowMove = index => {
35334
+ if (index === -1) {
35335
+ return;
35336
+ }
35337
+ if (index === currentIndexRef.current) {
35338
+ // Where the row already was — the first reading of all, and any move
35339
+ // this box has already taken note of (a render writes it too). What it
35340
+ // was still waiting for is here nonetheless, so the wait is called off.
35341
+ if (samePage(pageAskedForRef.current, pages[index])) {
35342
+ pageAskedForRef.current = null;
35343
+ }
35287
35344
  return;
35288
35345
  }
35346
+ const page = pages[index];
35289
35347
  // A page this box asked for itself — a travel's own navigation, or one
35290
35348
  // it had given up waiting on: what arrives here is the answer to a
35291
35349
  // question already answered, not somebody going somewhere.
35292
- if (routeAskedForRef.current === route) {
35293
- routeAskedForRef.current = null;
35350
+ if (samePage(pageAskedForRef.current, page)) {
35351
+ pageAskedForRef.current = null;
35294
35352
  currentIndexRef.current = index;
35295
35353
  return;
35296
35354
  }
@@ -35298,13 +35356,13 @@ const RouteTravel = ({
35298
35356
  // is not coming, or no longer means anything. Forgotten here rather
35299
35357
  // than kept, or the next press on that very tab would be taken for the
35300
35358
  // late answer to a question nobody remembers asking.
35301
- routeAskedForRef.current = null;
35359
+ pageAskedForRef.current = null;
35302
35360
  // Asked for a page while one was already on its way: the travel in
35303
35361
  // flight is the answer, aimed somewhere else. Starting a second one on
35304
35362
  // top would leave this one's pictures to be dropped mid-slide.
35305
35363
  if (travelRef.current) {
35306
35364
  currentIndexRef.current = index;
35307
- retargetTravel(travelRef.current, route);
35365
+ retargetTravel(travelRef.current, page);
35308
35366
  return;
35309
35367
  }
35310
35368
  const fromIndex = currentIndexRef.current;
@@ -35315,18 +35373,18 @@ const RouteTravel = ({
35315
35373
  return;
35316
35374
  }
35317
35375
  beginTravel({
35318
- route,
35319
- fromRoute: routes[fromIndex],
35376
+ page,
35377
+ fromPage: pages[fromIndex],
35320
35378
  direction: index > fromIndex ? "forward" : "back",
35321
35379
  scrub: false
35322
35380
  });
35323
- }));
35324
- return () => {
35325
- for (const unsubscribe of unsubscribes) {
35326
- unsubscribe();
35327
- }
35328
35381
  };
35329
- }, [routes]);
35382
+ // `subscribe` rather than `effect`: it hands the value to a callback that
35383
+ // is NOT being tracked, and what this one does — navigate, ask the router
35384
+ // for another page — reads and writes the very signals the row is watched
35385
+ // through.
35386
+ return currentIndexSignal.subscribe(onRowMove);
35387
+ }, [pages]);
35330
35388
 
35331
35389
  // Rendering is held for the length of a navigation, so that whatever picture
35332
35390
  // this box is about to take is of the page being LEFT (see holdRendering).
@@ -35363,6 +35421,13 @@ const RouteTravel = ({
35363
35421
  // Let go of far enough: the movement carries on from under the finger, at its
35364
35422
  // own pace, to the end.
35365
35423
  const finishTravel = travel => {
35424
+ // Nobody is driving it anymore. `scrub` is who MOVES the pictures, not what
35425
+ // set them off: left standing after the release, this travel would go on
35426
+ // claiming a hand that is no longer there — and everything that asks
35427
+ // "is somebody holding this?" before touching it (a press on the tab one
35428
+ // came from, a wheel push) would be answered yes and do nothing, while the
35429
+ // pages carry on to a page the router has already left.
35430
+ travel.scrub = false;
35366
35431
  releaseHold();
35367
35432
  travel.viewTransition.finished.then(() => endTravel(travel), () => endTravel(travel));
35368
35433
  };
@@ -35415,19 +35480,20 @@ const RouteTravel = ({
35415
35480
  Promise.resolve();
35416
35481
  backAtTheStart.then(async () => {
35417
35482
  try {
35418
- routeAskedForRef.current = travel.fromRoute;
35483
+ pageAskedForRef.current = travel.fromPage;
35419
35484
  // The page that was left is put back UNDER the picture before the
35420
35485
  // picture is dropped, so the two are the same thing at the moment they
35421
35486
  // are swapped: that only holds once the page is really back.
35422
- if (travel.fromRoute.matchingSignal.peek()) {
35487
+ if (pageIsCurrent(travel.fromPage)) {
35423
35488
  // It never left: the press that set this revert off put it back
35424
35489
  // there, and the pages have been held where they were until now.
35425
35490
  // Nothing to ask for, and nothing to wait for — waiting anyway is a
35426
35491
  // render that never comes and a page frozen under its own pictures.
35427
35492
  releaseRendering();
35428
35493
  } else {
35429
- await whileRouteRenders(travel.fromRoute, () => onTravel({
35430
- route: travel.fromRoute,
35494
+ await whilePageRenders(travel.fromPage, () => onTravel({
35495
+ route: travel.fromPage.route,
35496
+ params: travel.fromPage.params,
35431
35497
  cause: "revert"
35432
35498
  }));
35433
35499
  }
@@ -35466,8 +35532,8 @@ const RouteTravel = ({
35466
35532
  // change — only what is being brought in against it, and that one is LIVE:
35467
35533
  // pointing the router elsewhere is all it takes for the picture to show that
35468
35534
  // page instead.
35469
- const redirectTravel = (travel, route, direction) => {
35470
- travel.route = route;
35535
+ const redirectTravel = (travel, page, direction) => {
35536
+ travel.page = page;
35471
35537
  // Everything the transition carries that is NOT the pages was measured
35472
35538
  // against a destination this travel is no longer going to (see the CSS).
35473
35539
  document.documentElement.setAttribute(TURNED_ATTRIBUTE, "");
@@ -35488,28 +35554,28 @@ const RouteTravel = ({
35488
35554
 
35489
35555
  // Somebody asked for a page while one was on its way. Where they asked for
35490
35556
  // decides what that means.
35491
- const retargetTravel = (travel, route) => {
35557
+ const retargetTravel = (travel, page) => {
35492
35558
  if (travel.scrub || travel.reverting || travel.ended || travel.noPicture) {
35493
35559
  // A hand is holding the pages, or they are already on their way back:
35494
35560
  // either way this travel's end is decided by somebody else.
35495
35561
  return;
35496
35562
  }
35497
- if (route === travel.route) {
35563
+ if (samePage(page, travel.page)) {
35498
35564
  // Already on its way there.
35499
35565
  return;
35500
35566
  }
35501
- if (route === travel.fromRoute) {
35567
+ if (samePage(page, travel.fromPage)) {
35502
35568
  // Back where it set off from: that is not another travel, it is this one
35503
35569
  // undone — the same pictures, run backwards.
35504
35570
  revertTravel(travel);
35505
35571
  return;
35506
35572
  }
35507
- const fromIndex = routes.indexOf(travel.fromRoute);
35508
- const toIndex = routes.indexOf(route);
35573
+ const fromIndex = pageIndexOf(pages, travel.fromPage);
35574
+ const toIndex = pageIndexOf(pages, page);
35509
35575
  if (fromIndex === -1 || toIndex === -1) {
35510
35576
  return;
35511
35577
  }
35512
- redirectTravel(travel, route, toIndex > fromIndex ? "forward" : "back");
35578
+ redirectTravel(travel, page, toIndex > fromIndex ? "forward" : "back");
35513
35579
  };
35514
35580
  const endTravel = travel => {
35515
35581
  if (travel.ended) {
@@ -35523,6 +35589,10 @@ const RouteTravel = ({
35523
35589
  releaseHold(travel);
35524
35590
  if (travelRef.current === travel) {
35525
35591
  travelRef.current = null;
35592
+ // Given back, so another box may wear it: kept, two of them on a page
35593
+ // would both answer to it and the browser refuses the whole transition
35594
+ // rather than pick.
35595
+ unnameAfterTravel(elementRef.current);
35526
35596
  document.documentElement.removeAttribute(TRAVEL_ATTRIBUTE);
35527
35597
  document.documentElement.removeAttribute(DRAGGED_ATTRIBUTE);
35528
35598
  document.documentElement.removeAttribute(TURNED_ATTRIBUTE);
@@ -35587,24 +35657,21 @@ const RouteTravel = ({
35587
35657
  }
35588
35658
  // Dragging the page towards the end of the axis brings in what is
35589
35659
  // BEFORE it, the way pushing a sheet to the right reveals its left.
35590
- const route = sign > 0 ? routes[currentIndex - 1] : routes[currentIndex + 1];
35591
- if (!route || !size || scrollRoomTowards(target, elementRef.current, axis, sign)) {
35660
+ const page = sign > 0 ? pages[currentIndex - 1] : pages[currentIndex + 1];
35661
+ if (!page || !size || scrollRoomTowards(target, elementRef.current, axis, sign)) {
35592
35662
  return false;
35593
35663
  }
35594
35664
  if (CAN_KEEP_PICTURE) {
35595
35665
  beginTravel({
35596
- route,
35597
- fromRoute: routes[currentIndex],
35666
+ page,
35667
+ fromPage: pages[currentIndex],
35598
35668
  direction: sign > 0 ? "back" : "forward",
35599
35669
  scrub: true,
35600
- change: () => onTravel({
35601
- route,
35602
- cause: "drag"
35603
- })
35670
+ change: () => travelTo(page, "drag")
35604
35671
  });
35605
35672
  } else {
35606
35673
  travelRef.current = {
35607
- route,
35674
+ page,
35608
35675
  noPicture: true,
35609
35676
  ended: false
35610
35677
  };
@@ -35656,9 +35723,9 @@ const RouteTravel = ({
35656
35723
  // Where we are is what THIS travel was bringing in — the URL changed at
35657
35724
  // the first pixel, so `currentIndex` belongs to a render this gesture
35658
35725
  // is older than.
35659
- const fromIndex = routes.indexOf(travel.route);
35660
- const route = direction === "back" ? routes[fromIndex - 1] : routes[fromIndex + 1];
35661
- if (fromIndex === -1 || !route) {
35726
+ const fromIndex = pageIndexOf(pages, travel.page);
35727
+ const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
35728
+ if (fromIndex === -1 || !page) {
35662
35729
  return false;
35663
35730
  }
35664
35731
  // At their very end before they are let go of: what ends the travel in
@@ -35668,14 +35735,11 @@ const RouteTravel = ({
35668
35735
  scrubTravel(travel, 1);
35669
35736
  travel.ratio = 1;
35670
35737
  beginTravel({
35671
- route,
35672
- fromRoute: travel.route,
35738
+ page,
35739
+ fromPage: travel.page,
35673
35740
  direction,
35674
35741
  scrub: true,
35675
- change: () => onTravel({
35676
- route,
35677
- cause: "drag"
35678
- })
35742
+ change: () => travelTo(page, "drag")
35679
35743
  });
35680
35744
  return {
35681
35745
  size: boxSizeOnAxis(),
@@ -35690,17 +35754,14 @@ const RouteTravel = ({
35690
35754
  // other neighbour is enough for it to show that one instead. The travel
35691
35755
  // turns around where it stands, on the same transition and under the same
35692
35756
  // hand, and there is no gap at all.
35693
- const fromIndex = routes.indexOf(travel.fromRoute);
35694
- const route = direction === "back" ? routes[fromIndex - 1] : routes[fromIndex + 1];
35695
- if (fromIndex === -1 || !route) {
35757
+ const fromIndex = pageIndexOf(pages, travel.fromPage);
35758
+ const page = direction === "back" ? pages[fromIndex - 1] : pages[fromIndex + 1];
35759
+ if (fromIndex === -1 || !page) {
35696
35760
  return false;
35697
35761
  }
35698
- redirectTravel(travel, route, direction);
35699
- routeAskedForRef.current = route;
35700
- onTravel({
35701
- route,
35702
- cause: "drag"
35703
- });
35762
+ redirectTravel(travel, page, direction);
35763
+ pageAskedForRef.current = page;
35764
+ travelTo(page, "drag");
35704
35765
  return {
35705
35766
  size: boxSizeOnAxis(),
35706
35767
  travelBack: sign > 0,
@@ -35723,10 +35784,7 @@ const RouteTravel = ({
35723
35784
  if (travel.noPicture) {
35724
35785
  travelRef.current = null;
35725
35786
  if (travels) {
35726
- onTravel({
35727
- route: travel.route,
35728
- cause: "drag"
35729
- });
35787
+ travelTo(travel.page, "drag");
35730
35788
  }
35731
35789
  return;
35732
35790
  }
@@ -35758,6 +35816,16 @@ const RouteTravel = ({
35758
35816
  if (currentIndex === -1) {
35759
35817
  return;
35760
35818
  }
35819
+ // A press that never became a gesture: whatever it stopped goes on its way,
35820
+ // from where the finger caught it.
35821
+ const giveUp = () => {
35822
+ gestureRef.current = null;
35823
+ const caught = caughtAtPressRef.current;
35824
+ caughtAtPressRef.current = null;
35825
+ if (caught && !caught.ended) {
35826
+ releaseHold(caught);
35827
+ }
35828
+ };
35761
35829
  const gesture = startDragToTravel(pointerDownEvent, {
35762
35830
  element: elementRef.current,
35763
35831
  axes: axis,
@@ -35765,17 +35833,14 @@ const RouteTravel = ({
35765
35833
  // is answered from its first pixel, on the axis the pages travel.
35766
35834
  immediate: caughtAtPressRef.current ? axis : false,
35767
35835
  ...travelHandlers,
35768
- onGiveUp: () => {
35769
- gestureRef.current = null;
35770
- // A press that never became a gesture: whatever it stopped goes on its
35771
- // way, from where the finger caught it.
35772
- const caught = caughtAtPressRef.current;
35773
- caughtAtPressRef.current = null;
35774
- if (caught && !caught.ended) {
35775
- releaseHold(caught);
35776
- }
35777
- }
35836
+ onGiveUp: giveUp
35778
35837
  });
35838
+ if (!gesture) {
35839
+ // Not a press this box can be about — something that reads the pointer
35840
+ // itself, a box below it that travels the same way.
35841
+ giveUp();
35842
+ return;
35843
+ }
35779
35844
  gestureRef.current = gesture;
35780
35845
  };
35781
35846
 
@@ -35827,13 +35892,13 @@ const RouteTravel = ({
35827
35892
  }
35828
35893
  // Where the box is going, which is not where it is: a step asked for while
35829
35894
  // a travel plays is the page after the one on its way.
35830
- const fromRoute = travelInFlight ? travelInFlight.route : routes[currentIndex];
35831
- const fromIndex = routes.indexOf(fromRoute);
35895
+ const fromPage = travelInFlight ? travelInFlight.page : pages[currentIndex];
35896
+ const fromIndex = pageIndexOf(pages, fromPage);
35832
35897
  if (fromIndex === -1) {
35833
35898
  return;
35834
35899
  }
35835
- const route = sign > 0 ? routes[fromIndex - 1] : routes[fromIndex + 1];
35836
- if (!route) {
35900
+ const page = sign > 0 ? pages[fromIndex - 1] : pages[fromIndex + 1];
35901
+ if (!page) {
35837
35902
  return;
35838
35903
  }
35839
35904
  if (travelInFlight) {
@@ -35844,14 +35909,11 @@ const RouteTravel = ({
35844
35909
  travelInFlight.ratio = 1;
35845
35910
  }
35846
35911
  beginTravel({
35847
- route,
35848
- fromRoute,
35912
+ page,
35913
+ fromPage,
35849
35914
  direction: sign > 0 ? "back" : "forward",
35850
35915
  scrub: false,
35851
- change: () => onTravel({
35852
- route,
35853
- cause: "wheel"
35854
- })
35916
+ change: () => travelTo(page, "wheel")
35855
35917
  });
35856
35918
  };
35857
35919
 
@@ -35882,7 +35944,15 @@ const RouteTravel = ({
35882
35944
  // page (see drag_to_travel.js).
35883
35945
  ,
35884
35946
 
35885
- "data-drag-travel": travelByDrag ? axis : undefined,
35947
+ "data-drag-travel": travelByDrag ? axis : undefined
35948
+ // The same fact said once per gesture, and for the other question the
35949
+ // DOM answers: a box that travels INSIDE this one — a row of slides in a
35950
+ // page — takes the axis it walks, and these are what it reads to know
35951
+ // this box walks it too.
35952
+ ,
35953
+
35954
+ "data-travel-by-drag": travelByDrag ? axis : undefined,
35955
+ "data-travel-by-wheel": travelByDrag ? axis : undefined,
35886
35956
  onPointerDown: onPointerDown,
35887
35957
  children: children
35888
35958
  });
@@ -36076,7 +36146,7 @@ const scrubTravel = (travel, ratio) => {
36076
36146
  }
36077
36147
  };
36078
36148
 
36079
- // A route change, carried out and then waited for until the page it selects is
36149
+ // A page change, carried out and then waited for until the page it selects is
36080
36150
  // really on screen. The container doing the swapping is the only one who knows
36081
36151
  // when that is (observeRouteRender): a route matching is a signal changing, and
36082
36152
  // how many passes Preact takes to answer it is its own business.
@@ -36086,7 +36156,7 @@ const scrubTravel = (travel, ratio) => {
36086
36156
  // inside the callback of a view transition: the browser has stopped rendering
36087
36157
  // and is waiting on this very promise to take its picture, so a wait that never
36088
36158
  // ends is a page frozen under a transition that never became ready.
36089
- const whileRouteRenders = async (route, change) => {
36159
+ const whilePageRenders = async (page, change) => {
36090
36160
  let stopListening;
36091
36161
  const rendered = new Promise(resolve => {
36092
36162
  // Listened for before the change, or a render landing while the change is
@@ -36095,7 +36165,7 @@ const whileRouteRenders = async (route, change) => {
36095
36165
  });
36096
36166
  try {
36097
36167
  await change();
36098
- if (route.matchingSignal.peek()) {
36168
+ if (pageIsCurrent(page)) {
36099
36169
  await rendered;
36100
36170
  }
36101
36171
  } finally {
@@ -36103,9 +36173,76 @@ const whileRouteRenders = async (route, change) => {
36103
36173
  }
36104
36174
  };
36105
36175
 
36176
+ // A page of the row: a route, and the params that say which of its tabs when
36177
+ // several of them share it. Written as a bare route by a caller whose tabs are
36178
+ // routes of their own — which is the same page with nothing to tell apart.
36179
+ const normalizePage = page => page.isRoute ? {
36180
+ route: page,
36181
+ params: undefined
36182
+ } : page;
36183
+
36184
+ // Two pages are the same page when they select the same thing, not when they
36185
+ // were written by the same hand: the params of a tab are a literal in JSX, so
36186
+ // every render builds another object for what is plainly the same tab.
36187
+ const samePage = (a, b) => {
36188
+ if (a === b) {
36189
+ return true;
36190
+ }
36191
+ if (!a || !b) {
36192
+ return false;
36193
+ }
36194
+ return a.route === b.route && compareTwoJsValues(a.params, b.params);
36195
+ };
36196
+ const pageIndexOf = (pages, page) => pages.findIndex(candidate => samePage(candidate, page));
36197
+
36198
+ // Whether this page is the one on screen. `matchesParams` reads paramsSignal,
36199
+ // so a caller reading this during a render is subscribed to the param changes
36200
+ // that walk from one tab to the next — matchingSignal alone never moves there,
36201
+ // and a row whose tabs are params of one route would never re-render.
36202
+ //
36203
+ // The params are read only for a route that matches, and that is not a signal
36204
+ // left unread: a reader wakes on anything it read last time, so what matters is
36205
+ // that everything able to make this answer change is among them.
36206
+ // matchingSignal is read whatever happens, and it is a NECESSARY condition —
36207
+ // while it is false no param of that route can put this page on screen, and the
36208
+ // day one could, matchingSignal itself has to turn true to say so, which is the
36209
+ // read that brings the params back in. (Asking anyway would be worse than
36210
+ // useless: the params of a route that does not match are not params.)
36211
+ const pageIsCurrent = ({
36212
+ route,
36213
+ params
36214
+ }) => {
36215
+ if (!route.matchingSignal.value) {
36216
+ return false;
36217
+ }
36218
+ return params ? route.matchesParams(params) : true;
36219
+ };
36220
+ // Every page is read, never only up to the one that answers yes: a page that is
36221
+ // not the current one today is the one that must wake the reader tomorrow.
36222
+ const currentPageIndex = pages => {
36223
+ let currentIndex = -1;
36224
+ for (let i = 0; i < pages.length; i++) {
36225
+ if (pageIsCurrent(pages[i])) {
36226
+ currentIndex = i;
36227
+ }
36228
+ }
36229
+ return currentIndex;
36230
+ };
36231
+
36106
36232
  // A transition skipped by another one starting is an outcome, not a failure.
36107
36233
  const ignoreSkipped = () => {};
36108
36234
 
36235
+ // The name is lent to the box that is travelling and taken back afterwards.
36236
+ // There is one transition in a document at a time, so one box wears it at a
36237
+ // time — and the others, unnamed, are simply not captured: they stay live
36238
+ // under the pictures rather than being frozen with the page.
36239
+ const nameForTravel = element => {
36240
+ element.style.viewTransitionName = TRAVEL_NAME;
36241
+ };
36242
+ const unnameAfterTravel = element => {
36243
+ element.style.viewTransitionName = "";
36244
+ };
36245
+
36109
36246
  const routeAction = (
36110
36247
  routeOrRoutes,
36111
36248
  action,
@@ -38068,8 +38205,10 @@ const BinderItemContext = createContext(null);
38068
38205
 
38069
38206
  /**
38070
38207
  * What a <Link> learns from the <Nav> around it: where to draw the bar that
38071
- * says "you are here", and the name under which the browser is to recognise
38072
- * that bar from one page to the next (see nav.jsx).
38208
+ * says "you are here", the name under which the browser is to recognise that
38209
+ * bar from one page to the next, and — for a row of tabs that are slides — which
38210
+ * <SlideContainer> they are about and which of its slides is on screen (see
38211
+ * nav.jsx).
38073
38212
  */
38074
38213
  const NavContext = createContext(null);
38075
38214
 
@@ -38502,6 +38641,12 @@ Object.assign(PSEUDO_CLASSES, {
38502
38641
  * instead of a raw `href`: the URL is built from the route (see
38503
38642
  * `routeParams`) and "current" is derived from whether the route matches.
38504
38643
  * @param {object} [props.routeParams] - Params passed to `route.buildUrl`.
38644
+ * @param {string} [props.slide] - Makes this a tab for a slide rather than for
38645
+ * a URL: the area of a `<SlideContainer>` it goes to. The container is the one
38646
+ * the surrounding `<Nav slideContainer={id}>` names, and it is also what says
38647
+ * whether this tab is the current one. Nothing is written to the URL — these
38648
+ * are places within one screen, not pages of their own — so there is no href
38649
+ * and the tab behaves like a button.
38505
38650
  * @param {string} [props.target] - Native anchor target; defaults from
38506
38651
  * internal/external detection when omitted.
38507
38652
  * @param {string} [props.rel] - Native anchor rel; defaults to
@@ -38591,6 +38736,7 @@ const LinkPlain = props => {
38591
38736
  target,
38592
38737
  rel,
38593
38738
  anchor,
38739
+ slide,
38594
38740
  value = href,
38595
38741
  // visual
38596
38742
  variant,
@@ -38641,7 +38787,9 @@ const LinkPlain = props => {
38641
38787
  isAnchor,
38642
38788
  isCurrent
38643
38789
  } = getHrefTargetInfo(href);
38644
- const innerCurrent = current || isCurrent;
38790
+ // A tab that is a SLIDE is current when the container is on it — which the
38791
+ // <Nav> around reads off that container, so nothing here has to be told.
38792
+ const innerCurrent = current || (slide ? nav?.currentSlideArea === slide : isCurrent);
38645
38793
  useReportCurrentToBinderItem(innerCurrent);
38646
38794
  controlHostProps.basePseudoState = {
38647
38795
  ...basePseudoState,
@@ -38728,6 +38876,12 @@ const LinkPlain = props => {
38728
38876
  onClick,
38729
38877
  preventDefault
38730
38878
  } = props;
38879
+ // Travelling there is the container's business, said as the command anything
38880
+ // else in the page would say it with: the tab knows the name of a slide and
38881
+ // the id of the box, and nothing more about either.
38882
+ const goToSlide = (element, event) => {
38883
+ triggerNaviCommand(element, `--navi-go-to-slide:${slide}`, event);
38884
+ };
38731
38885
  return jsxs(Text, {
38732
38886
  as: "a",
38733
38887
  color: anchor && !innerChildren ? "inherit" : undefined,
@@ -38740,6 +38894,7 @@ const LinkPlain = props => {
38740
38894
  // was handed.
38741
38895
  preventDefault: undefined,
38742
38896
  anchor: undefined,
38897
+ slide: undefined,
38743
38898
  revealOnInteraction: undefined,
38744
38899
  variant: undefined,
38745
38900
  current: undefined,
@@ -38753,15 +38908,43 @@ const LinkPlain = props => {
38753
38908
  hrefFallback: undefined,
38754
38909
  onClick: e => {
38755
38910
  onClick?.(e);
38911
+ if (slide) {
38912
+ goToSlide(e.currentTarget, e);
38913
+ }
38756
38914
  if (preventDefault) {
38757
38915
  e.preventDefault();
38758
38916
  }
38917
+ }
38918
+ // A tab with no href is not a link the browser knows how to press: it is
38919
+ // focusable because it says so (tabIndex below) and it answers the two
38920
+ // keys a button answers, since that is what it behaves like.
38921
+ ,
38922
+
38923
+ onKeyDown: e => {
38924
+ props.onKeyDown?.(e);
38925
+ if (!slide || e.defaultPrevented) {
38926
+ return;
38927
+ }
38928
+ if (e.key === "Enter" || e.key === " ") {
38929
+ e.preventDefault();
38930
+ goToSlide(e.currentTarget, e);
38931
+ }
38759
38932
  },
38760
38933
  href: href,
38761
38934
  rel: innerRel,
38762
- target: innerTarget === "_self" ? undefined : target,
38935
+ target: innerTarget === "_self" ? undefined : target
38936
+ // Which slide this tab is, and which box to say it to: read by the <Nav>
38937
+ // around it to place the row's own bar, and by the command above to find
38938
+ // the container across the document.
38939
+ ,
38940
+
38941
+ "data-slide-target": slide,
38942
+ commandfor: slide ? nav?.slideContainer : undefined,
38943
+ "aria-controls": slide ? nav?.slideContainer : undefined,
38944
+ tabIndex: slide ? props.tabIndex ?? 0 : props.tabIndex,
38945
+ role: slide ? "tab" : props.role,
38763
38946
  "aria-current": isCurrent ? "page" : undefined,
38764
- "aria-selected": selectionContext ? selected : undefined,
38947
+ "aria-selected": slide ? innerCurrent : selectionContext ? selected : undefined,
38765
38948
  "data-value-event": "navi_value",
38766
38949
  onnavi_value: e => {
38767
38950
  e.detail.setValue(value);
@@ -38824,6 +39007,75 @@ const css$N = /* css */`
38824
39007
  --nav-padding: 0px;
38825
39008
  --nav-border-radius: 0px;
38826
39009
  --nav-background: transparent;
39010
+ --nav-current-indicator-size: 2px;
39011
+ --nav-current-indicator-color: var(--navi-link-current-indicator-color);
39012
+ }
39013
+ }
39014
+
39015
+ /* The bar of a nav whose tabs are SLIDES: one element for the whole row,
39016
+ placed over the current tab and interpolated towards the one the picture
39017
+ leans on (see paintIndicatorGeometry). The two ends are written in pixels
39018
+ as plain numbers, so the whole of the movement is a calc() the browser
39019
+ runs itself — the trait then follows a finger dragging the slides without a
39020
+ render per frame, and rides the same animation as the track when the travel
39021
+ was asked for rather than dragged.
39022
+ No named view transition here, unlike the bar of a nav made of routes:
39023
+ there is no transition to be part of — the slides travel under an animation
39024
+ of their own, which a finger can hold. */
39025
+ .navi_nav[data-nav-indicator] {
39026
+ position: relative;
39027
+
39028
+ > .navi_nav_indicator {
39029
+ --x-nav-indicator-position: calc(
39030
+ var(--nav-indicator-position) + var(--slide-travel-progress) *
39031
+ var(--nav-indicator-position-delta)
39032
+ );
39033
+ --x-nav-indicator-length: calc(
39034
+ var(--nav-indicator-length) + var(--slide-travel-progress) *
39035
+ var(--nav-indicator-length-delta)
39036
+ );
39037
+
39038
+ position: absolute;
39039
+ z-index: 1;
39040
+ background: var(--nav-current-indicator-color);
39041
+ border-radius: 0.1px;
39042
+ pointer-events: none;
39043
+ }
39044
+ /* Nothing to draw until the row has been measured: a tab bar whose current
39045
+ tab is not among its links (a container on a slide no tab names) has no
39046
+ place to put the trait. */
39047
+ &:not([data-nav-indicator-measured]) > .navi_nav_indicator {
39048
+ display: none;
39049
+ }
39050
+
39051
+ &[data-nav-indicator="top"],
39052
+ &[data-nav-indicator="bottom"] {
39053
+ > .navi_nav_indicator {
39054
+ left: calc(var(--x-nav-indicator-position) * 1px);
39055
+ width: calc(var(--x-nav-indicator-length) * 1px);
39056
+ height: var(--nav-current-indicator-size);
39057
+ }
39058
+ }
39059
+ &[data-nav-indicator="top"] > .navi_nav_indicator {
39060
+ top: 0;
39061
+ }
39062
+ &[data-nav-indicator="bottom"] > .navi_nav_indicator {
39063
+ bottom: 0;
39064
+ }
39065
+
39066
+ &[data-nav-indicator="left"],
39067
+ &[data-nav-indicator="right"] {
39068
+ > .navi_nav_indicator {
39069
+ top: calc(var(--x-nav-indicator-position) * 1px);
39070
+ width: var(--nav-current-indicator-size);
39071
+ height: calc(var(--x-nav-indicator-length) * 1px);
39072
+ }
39073
+ }
39074
+ &[data-nav-indicator="left"] > .navi_nav_indicator {
39075
+ left: 0;
39076
+ }
39077
+ &[data-nav-indicator="right"] > .navi_nav_indicator {
39078
+ right: 0;
38827
39079
  }
38828
39080
  }
38829
39081
 
@@ -38979,23 +39231,43 @@ const NavStyleCSSVars = {
38979
39231
  paddingRight: "--nav-padding-right",
38980
39232
  paddingBottom: "--nav-padding-bottom",
38981
39233
  paddingLeft: "--nav-padding-left",
38982
- background: "--nav-background"
39234
+ background: "--nav-background",
39235
+ currentIndicatorColor: "--nav-current-indicator-color",
39236
+ currentIndicatorSize: "--nav-current-indicator-size"
39237
+ };
39238
+ const positionOfCurrentIndicator = (currentIndicator, vertical) => {
39239
+ if (currentIndicator === true) {
39240
+ return vertical ? "left" : "bottom";
39241
+ }
39242
+ if (currentIndicator === "top" || currentIndicator === "bottom" || currentIndicator === "left" || currentIndicator === "right") {
39243
+ return currentIndicator;
39244
+ }
39245
+ return null;
38983
39246
  };
39247
+
38984
39248
  /**
38985
39249
  * @type {import("ignore:preact").FunctionComponent<{
38986
39250
  * currentIndicator?: boolean|"top"|"bottom"|"left"|"right",
38987
39251
  * currentIndicatorSlides?: boolean,
39252
+ * slideContainer?: string,
38988
39253
  * }>}
38989
39254
  * @param {boolean|"top"|"bottom"|"left"|"right"} [props.currentIndicator] - the
38990
39255
  * bar that says which tab one is on, said once here rather than on every
38991
39256
  * `<Link>`. A link may still say otherwise for itself.
38992
39257
  * @param {boolean} [props.currentIndicatorSlides=true] - whether that bar
38993
39258
  * travels from the tab it was under to the tab it is under now, instead of
38994
- * going out on one and coming back on the other. It does so by being NAMED,
38995
- * which is all the browser needs: any change played as a view transition
38996
- * animates it on the same clock as everything else in that transition. Inside
38997
- * a `RouteTravel` that means it follows the pages, and the thumb dragging
38998
- * them, without either of them being told about the other.
39259
+ * going out on one and coming back on the other. For a nav made of routes it
39260
+ * does so by being NAMED, which is all the browser needs: any change played as
39261
+ * a view transition animates it on the same clock as everything else in that
39262
+ * transition. Inside a `RouteTravel` that means it follows the pages, and the
39263
+ * thumb dragging them, without either of them being told about the other. For
39264
+ * a nav made of slides (`slideContainer`) the bar is one element for the whole
39265
+ * row, and it reads the travel the container publishes.
39266
+ * @param {string} [props.slideContainer] - the id of a `<SlideContainer>` these
39267
+ * tabs are about: each one says which slide it is (`<Link slide="…">`), the
39268
+ * container says which one is on screen, and pressing a tab travels there.
39269
+ * Tabs that are places in the same screen rather than pages of their own —
39270
+ * nothing is written to the URL and nothing is a link.
38999
39271
  */
39000
39272
  const Nav = ({
39001
39273
  children,
@@ -39008,21 +39280,132 @@ const Nav = ({
39008
39280
  currentIndicatorSlides = true,
39009
39281
  panelPosition,
39010
39282
  // "before" or "after": which side the panel sits on, turning the nav into folder tabs
39283
+ slideContainer,
39011
39284
  ...props
39012
39285
  }) => {
39013
39286
  import.meta.css = [css$N, "@jsenv/navi/src/nav/link/nav.jsx"];
39287
+ const defaultRef = useRef();
39288
+ props.ref = props.ref || defaultRef;
39289
+ const navRef = props.ref;
39014
39290
  const indicatorNameRef = useRef(null);
39015
39291
  if (indicatorNameRef.current === null) {
39016
39292
  indicatorNameRef.current = `navi-nav-indicator-${++navCount}`;
39017
39293
  }
39294
+ const [currentSlideArea, setCurrentSlideArea] = useState(undefined);
39295
+ const slideContainerElementRef = useRef(null);
39296
+ const indicatorPosition = slideContainer ? positionOfCurrentIndicator(currentIndicator, vertical) : null;
39297
+
39298
+ // Where the trait is and where it is headed, as four numbers of pixels the
39299
+ // CSS above interpolates between (see the .navi_nav_indicator rules). Written
39300
+ // by hand rather than rendered: it is read off the row as it stands, and the
39301
+ // travel it must agree with starts in the same frame the container publishes
39302
+ // it — a render would land after the movement had begun.
39303
+ const paintIndicatorGeometry = () => {
39304
+ const navElement = navRef.current;
39305
+ const containerElement = slideContainerElementRef.current;
39306
+ if (!navElement || !containerElement || !indicatorPosition) {
39307
+ return;
39308
+ }
39309
+ const tabElements = Array.from(navElement.querySelectorAll("[data-slide-target]"));
39310
+ const areaOf = tabElement => tabElement.getAttribute("data-slide-target");
39311
+ const currentArea = containerElement.getAttribute("data-slide-current");
39312
+ const currentIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === currentArea);
39313
+ if (currentIndex === -1) {
39314
+ // On a slide no tab in this row names: there is no tab to sit under.
39315
+ navElement.removeAttribute("data-nav-indicator-measured");
39316
+ return;
39317
+ }
39318
+ const measure = tabElement => vertical ? {
39319
+ position: tabElement.offsetTop,
39320
+ length: tabElement.offsetHeight
39321
+ } : {
39322
+ position: tabElement.offsetLeft,
39323
+ length: tabElement.offsetWidth
39324
+ };
39325
+ const currentMeasure = measure(tabElements[currentIndex]);
39326
+ const towardArea = containerElement.getAttribute("data-slide-travel-toward");
39327
+ const towardIndex = tabElements.findIndex(tabElement => areaOf(tabElement) === towardArea);
39328
+ let positionDelta = 0;
39329
+ let lengthDelta = 0;
39330
+ if (towardIndex !== -1 && towardIndex !== currentIndex) {
39331
+ const towardMeasure = measure(tabElements[towardIndex]);
39332
+ // What one box of travel is worth in pixels of this row, signed so that
39333
+ // the trait is exactly on the other tab when the progress is at its own
39334
+ // end: the container counts +1 when the picture leans on a slide sitting
39335
+ // BEFORE the current one and -1 when it sits after.
39336
+ const sign = towardIndex > currentIndex ? -1 : 1;
39337
+ positionDelta = (towardMeasure.position - currentMeasure.position) * sign;
39338
+ lengthDelta = (towardMeasure.length - currentMeasure.length) * sign;
39339
+ }
39340
+ const {
39341
+ style
39342
+ } = navElement;
39343
+ style.setProperty("--nav-indicator-position", currentMeasure.position);
39344
+ style.setProperty("--nav-indicator-length", currentMeasure.length);
39345
+ style.setProperty("--nav-indicator-position-delta", positionDelta);
39346
+ style.setProperty("--nav-indicator-length-delta", lengthDelta);
39347
+ navElement.setAttribute("data-nav-indicator-measured", "");
39348
+ };
39349
+ // Reached through a ref by everything watching the DOM below: those watchers
39350
+ // outlive a render, and what they must run is the version of this that knows
39351
+ // about the row as it is now.
39352
+ const paintIndicatorGeometryRef = useRef(null);
39353
+ paintIndicatorGeometryRef.current = paintIndicatorGeometry;
39354
+ useLayoutEffect(() => {
39355
+ if (!slideContainer) {
39356
+ return undefined;
39357
+ }
39358
+ const containerElement = document.getElementById(slideContainer);
39359
+ if (!containerElement) {
39360
+ console.warn(`<Nav slideContainer="${slideContainer}"> but no element with that id found`);
39361
+ return undefined;
39362
+ }
39363
+ slideContainerElementRef.current = containerElement;
39364
+ const readContainer = () => {
39365
+ setCurrentSlideArea(containerElement.getAttribute("data-slide-current") ?? undefined);
39366
+ paintIndicatorGeometryRef.current();
39367
+ };
39368
+ readContainer();
39369
+ // The container says where one is and what the picture leans on, and says
39370
+ // it in the DOM: nothing here is told, everything is read — which is what
39371
+ // lets this row sit anywhere on the page (above the box, in a fixed bar)
39372
+ // rather than inside it.
39373
+ const attributeObserver = new MutationObserver(readContainer);
39374
+ attributeObserver.observe(containerElement, {
39375
+ attributes: true,
39376
+ attributeFilter: ["data-slide-current", "data-slide-travel-toward"]
39377
+ });
39378
+ // A row whose tabs changed width — a badge count, a font that just
39379
+ // arrived, a window resized — is measured again: what was written is
39380
+ // pixels, and pixels go stale.
39381
+ const sizeObserver = new ResizeObserver(() => {
39382
+ paintIndicatorGeometryRef.current();
39383
+ });
39384
+ sizeObserver.observe(navRef.current);
39385
+ return () => {
39386
+ attributeObserver.disconnect();
39387
+ sizeObserver.disconnect();
39388
+ slideContainerElementRef.current = null;
39389
+ };
39390
+ }, [slideContainer]);
39391
+
39392
+ // Said after every commit: a tab added, removed or renamed moves the trait,
39393
+ // and no observer above watches this row's own children.
39394
+ useLayoutEffect(() => {
39395
+ paintIndicatorGeometry();
39396
+ });
39018
39397
  const navContextValue = useMemo(() => ({
39019
- currentIndicator,
39398
+ // The bar belongs to the row itself when the tabs are slides, so the
39399
+ // links draw none of their own.
39400
+ currentIndicator: slideContainer ? undefined : currentIndicator,
39020
39401
  // Read by the link that is current, and by it alone: a name belongs to
39021
39402
  // one element at a time, and the bar exists in every tab.
39022
- indicatorName: currentIndicatorSlides ? indicatorNameRef.current : null
39023
- }), [currentIndicator, currentIndicatorSlides]);
39403
+ indicatorName: currentIndicatorSlides ? indicatorNameRef.current : null,
39404
+ slideContainer,
39405
+ currentSlideArea
39406
+ }), [currentIndicator, currentIndicatorSlides, slideContainer, currentSlideArea]);
39024
39407
  children = toChildArray(children);
39025
- return jsx(Box, {
39408
+ return jsxs(Box, {
39026
39409
  as: "nav",
39027
39410
  row: vertical,
39028
39411
  column: !vertical,
@@ -39031,15 +39414,30 @@ const Nav = ({
39031
39414
  "data-expand": expand || expandX ? "" : undefined,
39032
39415
  "data-vertical": vertical ? "" : undefined,
39033
39416
  "data-panel-position": panelPosition,
39417
+ "data-nav-indicator": indicatorPosition ?? undefined
39418
+ // "write your travel here too": a custom property cannot be read across
39419
+ // the DOM, so the container paints its progress onto this element and the
39420
+ // trait follows in CSS alone (see SlideContainer's followerElements).
39421
+ ,
39422
+
39423
+ "data-slide-container-follows": slideContainer
39424
+ // Tabs over one screen, not links to pages: a screen reader is told so,
39425
+ // and told which way the row runs.
39426
+ ,
39427
+
39428
+ role: slideContainer ? "tablist" : undefined,
39429
+ "aria-orientation": slideContainer && vertical ? "vertical" : undefined,
39034
39430
  expand: expand,
39035
39431
  expandX: expandX,
39036
39432
  spacing: spacing,
39037
39433
  ...props,
39038
39434
  styleCSSVars: NavStyleCSSVars,
39039
- children: jsx(NavContext.Provider, {
39435
+ children: [indicatorPosition && jsx("span", {
39436
+ className: "navi_nav_indicator"
39437
+ }), jsx(NavContext.Provider, {
39040
39438
  value: navContextValue,
39041
39439
  children: children
39042
- })
39440
+ })]
39043
39441
  });
39044
39442
  };
39045
39443
 
@@ -45922,7 +46320,16 @@ const SlideContainer = ({
45922
46320
  // picture is from the slide ARRIVING when the travel starts, in boxes. Null
45923
46321
  // for a travel nobody dragged, where a whole box is what is left to close.
45924
46322
  const travelProgressFromRef = useRef(null);
45925
- const progressAnimationRef = useRef(null);
46323
+ // One per element painting the progress: the box, plus everything following
46324
+ // it (see followerElementsRef). All started together and with the same
46325
+ // options, so they are one movement said in several places.
46326
+ const progressAnimationsRef = useRef([]);
46327
+ // Elements outside the box that draw something about this travel — a tab bar
46328
+ // above it, most of all. A custom property cannot be read across the DOM, so
46329
+ // the progress is WRITTEN on each of them: they then interpolate whatever they
46330
+ // draw in CSS alone, at the pace of the travel and under the finger, with
46331
+ // nothing measured per frame.
46332
+ const followerElementsRef = useRef([]);
45926
46333
  const current = rollingArea ?? provisionalArea ?? currentProp ?? currentAreaState;
45927
46334
  const vertical = layout === "column";
45928
46335
  // What the map has, and what each way of asking is allowed to use of it.
@@ -46048,6 +46455,28 @@ const SlideContainer = ({
46048
46455
  ...map
46049
46456
  };
46050
46457
  };
46458
+
46459
+ // Who is drawing something about this box from outside it: a tab bar saying
46460
+ // where one is, a row of dots. They name the box they follow by its id, the
46461
+ // way everything else that talks to it across the document does (commandfor).
46462
+ const readFollowerElements = () => {
46463
+ const containerEl = containerRef.current;
46464
+ const {
46465
+ id
46466
+ } = containerEl;
46467
+ if (!id) {
46468
+ return [];
46469
+ }
46470
+ return Array.from(document.querySelectorAll(`[data-slide-container-follows="${CSS.escape(id)}"]`));
46471
+ };
46472
+
46473
+ // Which slide is on screen, said in the DOM: it is what anything outside the
46474
+ // box reads to know where one is (a tab bar marking its current tab), and
46475
+ // there is nothing else for it to read — a slide the container holds by
46476
+ // itself is known to no one else.
46477
+ const paintCurrentArea = area => {
46478
+ containerRef.current?.setAttribute("data-slide-current", area);
46479
+ };
46051
46480
  const markAnswered = area => {
46052
46481
  const order = readMap().slideElements.map(readArea);
46053
46482
  const rank = order.indexOf(area);
@@ -46130,6 +46559,10 @@ const SlideContainer = ({
46130
46559
  // the children free — their shape says nothing about the arrangement, the map
46131
46560
  // does — and it is also the only place that has to agree with itself.
46132
46561
  useLayoutEffect(() => {
46562
+ // Read on every render rather than subscribed to: a follower says who it
46563
+ // follows in the DOM, and the render that mounted one is the render this
46564
+ // runs after.
46565
+ followerElementsRef.current = readFollowerElements();
46133
46566
  const {
46134
46567
  slideElements,
46135
46568
  placeOf
@@ -46142,6 +46575,7 @@ const SlideContainer = ({
46142
46575
  // shown, the way a stack of pages opens on its first page.
46143
46576
  slideElements[0];
46144
46577
  const currentArea = readArea(currentElement);
46578
+ paintCurrentArea(currentArea);
46145
46579
  const realPlaceOf = area => placeOf.get(area) || {
46146
46580
  x: 0,
46147
46581
  y: 0
@@ -46287,7 +46721,9 @@ const SlideContainer = ({
46287
46721
  // there was one, from a whole box away when the travel was asked for.
46288
46722
  const progressFrom = travelProgressFromRef.current ?? (travelStep ? travelStep.x || travelStep.y : 0);
46289
46723
  travelProgressFromRef.current = null;
46290
- animateTravelProgress(progressFrom, durationMs * travelRatio, easing);
46724
+ // The slide the picture leans on for the length of it is the one being
46725
+ // LEFT: it is the second one in the frame until the travel is over.
46726
+ animateTravelProgress(progressFrom, durationMs * travelRatio, easing, drawnArea);
46291
46727
  // Presses still waiting behind this one: it is already late, so it is
46292
46728
  // sent home at once rather than played out at the pace of someone who
46293
46729
  // has stopped pressing. Someone pressing → four times is asking to be
@@ -46707,6 +47143,30 @@ const SlideContainer = ({
46707
47143
  paintTravelProgress(drag.progress, drag.areaPulled);
46708
47144
  };
46709
47145
 
47146
+ // Everything that draws this travel: the box itself and whoever follows it.
47147
+ const travelPainters = () => {
47148
+ const containerEl = containerRef.current;
47149
+ if (!containerEl) {
47150
+ return [];
47151
+ }
47152
+ return [containerEl, ...followerElementsRef.current];
47153
+ };
47154
+
47155
+ // Which OTHER slide the picture leans on while it is not on the current one:
47156
+ // the slide being pulled in under a finger, the slide being left during a
47157
+ // travel. Two slides are in the frame and the number below says how far
47158
+ // between them one is — this says which the second one is, so a trait can be
47159
+ // drawn between two places rather than merely offset from one.
47160
+ const paintTravelToward = area => {
47161
+ for (const element of travelPainters()) {
47162
+ if (area) {
47163
+ element.setAttribute("data-slide-travel-toward", area);
47164
+ } else {
47165
+ element.removeAttribute("data-slide-travel-toward");
47166
+ }
47167
+ }
47168
+ };
47169
+
46710
47170
  // Where the picture stands relative to the slide that is CURRENT, in boxes:
46711
47171
  // 0 on it, +1 one whole box before it, -1 one box after. Written on the
46712
47172
  // container so an indicator drawn inside the box — a tab bar, a dot row, a
@@ -46715,45 +47175,50 @@ const SlideContainer = ({
46715
47175
  // gesture, so the number stays continuous when the travel commits and the
46716
47176
  // current slide changes under it.
46717
47177
  const paintTravelProgress = (progress, area) => {
46718
- const containerEl = containerRef.current;
46719
- if (!containerEl) {
46720
- return;
46721
- }
46722
- if (!progress) {
46723
- containerEl.style.removeProperty("--slide-travel-progress");
46724
- containerEl.removeAttribute("data-slide-travel-to");
46725
- return;
47178
+ for (const element of travelPainters()) {
47179
+ if (progress) {
47180
+ element.style.setProperty("--slide-travel-progress", progress);
47181
+ } else {
47182
+ element.style.removeProperty("--slide-travel-progress");
47183
+ }
46726
47184
  }
46727
- containerEl.style.setProperty("--slide-travel-progress", progress);
46728
- if (area) {
46729
- containerEl.setAttribute("data-slide-travel-to", area);
46730
- } else {
46731
- containerEl.removeAttribute("data-slide-travel-to");
47185
+ paintTravelToward(progress ? area : null);
47186
+ };
47187
+ const cancelTravelProgressAnimation = () => {
47188
+ for (const animation of progressAnimationsRef.current) {
47189
+ animation.cancel();
46732
47190
  }
47191
+ progressAnimationsRef.current = [];
46733
47192
  };
46734
47193
 
46735
47194
  // The indicator, brought home at the pace of the travel it belongs to: the
46736
47195
  // same duration and the same easing as the track, so the trait and the slides
46737
47196
  // are one movement. The value it lands on is the one nothing writes (0), so
46738
- // the animation is left to fall away on its own.
46739
- const animateTravelProgress = (from, durationMs, easing) => {
46740
- const containerEl = containerRef.current;
46741
- progressAnimationRef.current?.cancel();
46742
- progressAnimationRef.current = null;
47197
+ // the animation is left to fall away on its own — only the name of the slide
47198
+ // being leant on is taken back by hand, at the end.
47199
+ const animateTravelProgress = (from, durationMs, easing, area) => {
47200
+ // Read again at the start of every travel, not only at every render: a
47201
+ // follower appearing does not make this box render, and the travel it must
47202
+ // draw is the one about to start.
47203
+ followerElementsRef.current = readFollowerElements();
47204
+ cancelTravelProgressAnimation();
46743
47205
  paintTravelProgress(0);
46744
- if (!containerEl || !from || !durationMs) {
47206
+ const painters = travelPainters();
47207
+ if (!painters.length || !from || !durationMs) {
46745
47208
  return;
46746
47209
  }
46747
- progressAnimationRef.current = containerEl.animate([{
47210
+ paintTravelToward(area);
47211
+ progressAnimationsRef.current = painters.map(element => element.animate([{
46748
47212
  "--slide-travel-progress": from
46749
47213
  }, {
46750
47214
  "--slide-travel-progress": 0
46751
47215
  }], {
46752
47216
  duration: durationMs,
46753
47217
  easing
46754
- });
46755
- progressAnimationRef.current.finished.then(() => {
46756
- progressAnimationRef.current = null;
47218
+ }));
47219
+ progressAnimationsRef.current[0].finished.then(() => {
47220
+ progressAnimationsRef.current = [];
47221
+ paintTravelToward(null);
46757
47222
  }, () => {
46758
47223
  // cancelled by the next travel — that one says where the trait goes
46759
47224
  });
@@ -46826,7 +47291,7 @@ const SlideContainer = ({
46826
47291
  settleTravel();
46827
47292
  return;
46828
47293
  }
46829
- animateTravelProgress(drag.progress, durationMs * (pulled / size), "ease-out");
47294
+ animateTravelProgress(drag.progress, durationMs * (pulled / size), "ease-out", drag.areaPulled);
46830
47295
  const animation = track.animate([{
46831
47296
  translate: drag.offset
46832
47297
  }, {
@@ -46952,8 +47417,8 @@ const SlideContainer = ({
46952
47417
  caughtTravel = null;
46953
47418
  trackAnimationRef.current?.cancel();
46954
47419
  trackAnimationRef.current = null;
46955
- progressAnimationRef.current?.cancel();
46956
- progressAnimationRef.current = null;
47420
+ cancelTravelProgressAnimation();
47421
+ followerElementsRef.current = readFollowerElements();
46957
47422
  drag.axis = axis;
46958
47423
  drag.areaBack = areaBack;
46959
47424
  drag.areaOn = areaOn;
@@ -47098,6 +47563,10 @@ const SlideContainer = ({
47098
47563
  ...handlers
47099
47564
  });
47100
47565
  if (!gesture) {
47566
+ // Not a press this box can be about — something that reads the pointer
47567
+ // itself, a box below it that travels the same way. Whatever the press
47568
+ // stopped on its way in goes back on its way.
47569
+ handlers.onGiveUp();
47101
47570
  return;
47102
47571
  }
47103
47572
  handlers.drag.gesture = gesture;
@@ -47144,7 +47613,7 @@ const SlideContainer = ({
47144
47613
  return () => {
47145
47614
  dragRef.current?.gesture?.stop();
47146
47615
  dragRef.current = null;
47147
- progressAnimationRef.current?.cancel();
47616
+ cancelTravelProgressAnimation();
47148
47617
  };
47149
47618
  }, []);
47150
47619
 
@@ -47209,10 +47678,16 @@ const SlideContainer = ({
47209
47678
  "data-slide-container": ""
47210
47679
  // Which axes a touch may travel on, said in the DOM: what the browser
47211
47680
  // does with a finger is decided by CSS (touch-action) before any of this
47212
- // has seen the gesture.
47681
+ // has seen the gesture — and it is also what a box HOLDING this one reads
47682
+ // to know the gesture is not its own (see drag_to_travel.js).
47213
47683
  ,
47214
47684
 
47215
47685
  "data-travel-by-drag": dragAxes ?? undefined
47686
+ // The same fact for a wheel, and only for that second reason: this box
47687
+ // takes the push, whatever the box around it also travels on.
47688
+ ,
47689
+
47690
+ "data-travel-by-wheel": scrollAxes ?? undefined
47216
47691
  // The same fact, read by the shared gesture stylesheet: what scrolls
47217
47692
  // inside a box that travels must not spill onto the page behind it (see
47218
47693
  // drag_to_travel.js).
@@ -59490,8 +59965,16 @@ const useWheelInteractions = ({
59490
59965
  document.removeEventListener("wheel", onDocumentWheel, {
59491
59966
  capture: true
59492
59967
  });
59968
+ releaseWheelGesture(vp);
59493
59969
  };
59494
59970
  const keepClaimingGesture = () => {
59971
+ // Said out loud as well as swallowed: preventDefault only settles it with
59972
+ // the browser, and a box that travels with the wheel answers the burst
59973
+ // from a listener of its own — it asks who owns the gesture instead (see
59974
+ // wheel_gesture.js in @jsenv/dom).
59975
+ claimWheelGesture(vp, {
59976
+ delay: WHEEL_GESTURE_MAX_GAP
59977
+ });
59495
59978
  if (!gestureGuardTimer) {
59496
59979
  document.addEventListener("wheel", onDocumentWheel, {
59497
59980
  capture: true,
@@ -59502,6 +59985,12 @@ const useWheelInteractions = ({
59502
59985
  gestureGuardTimer = setTimeout(stopClaimingGesture, WHEEL_GESTURE_MAX_GAP);
59503
59986
  };
59504
59987
  const onWheel = e => {
59988
+ // The burst belongs to something else — a box that travels with the
59989
+ // wheel, another wheel the pointer has just left. It is theirs until the
59990
+ // events stop coming.
59991
+ if (wheelGestureIsTakenFrom(vp)) {
59992
+ return;
59993
+ }
59505
59994
  const raw = isHorizontal ? e.deltaX || e.deltaY : e.deltaY;
59506
59995
  if (!raw) {
59507
59996
  return;