@jsenv/navi 0.29.90 → 0.29.91

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,7 +4,7 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, clickIsSuppressed, isTouchDrivenEvent, scrollIntoViewScoped, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
7
+ import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, clickIsSuppressed, isTouchDrivenEvent, scrollIntoViewScoped, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, stringifyStyle as stringifyStyle$1, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, batch, untracked, useSignal } from "@preact/signals";
10
10
  import { createContext, isValidElement, h, Fragment, render, toChildArray, options, cloneElement } from "preact";
@@ -38,7 +38,7 @@ installImportMetaCssBuild(import.meta);/**
38
38
  * any of these, and a number is the last resort, not the first tool.
39
39
  */
40
40
 
41
- const css$12 = /* css */`
41
+ const css$13 = /* css */`
42
42
  @layer navi {
43
43
  :root {
44
44
  /* A control that overlaps its neighbours (the members of a Group share
@@ -92,7 +92,7 @@ const css$12 = /* css */`
92
92
  }
93
93
  }
94
94
  `;
95
- import.meta.css = [css$12, "@jsenv/navi/src/navi_z_indexes.js"];
95
+ import.meta.css = [css$13, "@jsenv/navi/src/navi_z_indexes.js"];
96
96
 
97
97
  const addIntoArray = (array, ...valuesToAdd) => {
98
98
  if (valuesToAdd.length === 1) {
@@ -361,7 +361,7 @@ installImportMetaCssBuild(import.meta);/**
361
361
  * the very first render and the browser does everything on its own.
362
362
  */
363
363
  const URL_TARGET_ATTRIBUTE = "data-url-target";
364
- const css$11 = /* css */`
364
+ const css$12 = /* css */`
365
365
  @layer navi {
366
366
  [${URL_TARGET_ATTRIBUTE}] {
367
367
  animation: navi_url_target var(--navi-url-target-duration, 2000ms)
@@ -379,7 +379,7 @@ const css$11 = /* css */`
379
379
  }
380
380
  }
381
381
  `;
382
- import.meta.css = [css$11, "@jsenv/navi/src/nav/url_target/url_target.js"];
382
+ import.meta.css = [css$12, "@jsenv/navi/src/nav/url_target/url_target.js"];
383
383
  let urlTargetOptions = {
384
384
  block: "center",
385
385
  behavior: "smooth",
@@ -6403,7 +6403,7 @@ installImportMetaCssBuild(import.meta);/**
6403
6403
  * - Arrow automatically shows when pointing at a valid anchor element
6404
6404
  * - Centers in viewport when no anchor element provided or anchor is too big
6405
6405
  */
6406
- const css$10 = /* css */`
6406
+ const css$11 = /* css */`
6407
6407
  @layer navi {
6408
6408
  .navi_callout {
6409
6409
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -6646,7 +6646,7 @@ const openCallout = (message, {
6646
6646
  skipFocus = false,
6647
6647
  debug = () => {}
6648
6648
  } = {}) => {
6649
- import.meta.css = [css$10, "@jsenv/navi/src/control/rules/callout/callout.js"];
6649
+ import.meta.css = [css$11, "@jsenv/navi/src/control/rules/callout/callout.js"];
6650
6650
  if (debug === true) {
6651
6651
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
6652
6652
  }
@@ -8009,7 +8009,14 @@ const isControlBusy = (field) => {
8009
8009
  return true;
8010
8010
  }
8011
8011
  const { boundAction } = field;
8012
- if (boundAction && boundAction.runningStateSignal.value === RUNNING) {
8012
+ // An optimistic control stays interactive while its bound action runs:
8013
+ // a new interaction is queued behind the run (see the action queue in
8014
+ // control_hooks.jsx) rather than refused.
8015
+ if (
8016
+ !field.optimistic &&
8017
+ boundAction &&
8018
+ boundAction.runningStateSignal.value === RUNNING
8019
+ ) {
8013
8020
  return true;
8014
8021
  }
8015
8022
  if (field.loadingFromParent) {
@@ -18866,7 +18873,7 @@ const setupNetworkMonitoring = () => {
18866
18873
  };
18867
18874
  setupNetworkMonitoring();
18868
18875
 
18869
- installImportMetaCssBuild(import.meta);const css$$ = /* css */`
18876
+ installImportMetaCssBuild(import.meta);const css$10 = /* css */`
18870
18877
  .navi_loading_indicator_fluid_container {
18871
18878
  position: relative;
18872
18879
  display: flex;
@@ -18898,7 +18905,7 @@ const LoadingIndicatorFluid = ({
18898
18905
  visuallyHidden,
18899
18906
  ...rest
18900
18907
  }) => {
18901
- import.meta.css = [css$$, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
18908
+ import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
18902
18909
  const ref = useRef(null);
18903
18910
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
18904
18911
  const [containerWidth, setContainerWidth] = useState(0);
@@ -19103,7 +19110,7 @@ const LoadingRectangleSvg = ({
19103
19110
  });
19104
19111
  };
19105
19112
 
19106
- installImportMetaCssBuild(import.meta);const css$_ = /* css */`
19113
+ installImportMetaCssBuild(import.meta);const css$$ = /* css */`
19107
19114
  .navi_loading_outline_wrapper {
19108
19115
  position: absolute;
19109
19116
  /* Controls place the outline slightly outside their box, right on top of
@@ -19140,7 +19147,7 @@ installImportMetaCssBuild(import.meta);const css$_ = /* css */`
19140
19147
  }
19141
19148
  `;
19142
19149
  const LoadingOutline = props => {
19143
- import.meta.css = [css$_, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
19150
+ import.meta.css = [css$$, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
19144
19151
  if (props.containerRef) {
19145
19152
  const container = props.containerRef.current;
19146
19153
  if (!container) {
@@ -19430,7 +19437,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
19430
19437
  };
19431
19438
 
19432
19439
  installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
19433
- const css$Z = /* css */`
19440
+ const css$_ = /* css */`
19434
19441
  @layer navi {
19435
19442
  .navi_text {
19436
19443
  &[data-skeleton] {
@@ -19951,7 +19958,7 @@ const TextShrinkWrap = props => {
19951
19958
  });
19952
19959
  };
19953
19960
  const TextUI = props => {
19954
- import.meta.css = [css$Z, "@jsenv/navi/src/text/text.jsx"];
19961
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
19955
19962
  let {
19956
19963
  ref,
19957
19964
  spacing,
@@ -23198,6 +23205,7 @@ const CONTROL_PROP_SET = new Set([
23198
23205
  "resetOnCancel",
23199
23206
  "resetOnAbort",
23200
23207
  "resetOnError",
23208
+ "optimistic",
23201
23209
 
23202
23210
  "charGuard",
23203
23211
  "maxLengthGuard",
@@ -24422,6 +24430,18 @@ const GROUP_DEFAULTS = {
24422
24430
  if (isNamelessGrouping(child, child.uiState)) {
24423
24431
  return newUIState;
24424
24432
  }
24433
+ if (
24434
+ newUIState !== null &&
24435
+ typeof newUIState === "object" &&
24436
+ Object.keys(newUIState).length === 0
24437
+ ) {
24438
+ // An object with nothing in it is not a partial answer, it is the
24439
+ // absence of one — a group being cleared — and every child empties. A
24440
+ // partial object leaves the children it does not name alone, which is
24441
+ // what CANNOT_DERIVE says below: a value that mentions `start` says
24442
+ // nothing about `end`, an empty one says there is no answer at all.
24443
+ return undefined;
24444
+ }
24425
24445
  return CANNOT_DERIVE;
24426
24446
  },
24427
24447
  },
@@ -25536,17 +25556,21 @@ const resolveClearedUIState = (controller) => {
25536
25556
  return "";
25537
25557
  };
25538
25558
 
25539
- // What a control says when it has nothing to say: no value at all, or the empty
25540
- // array/object a group falls back to while it has no child to aggregate.
25559
+ // What a control says when it has nothing to say: no value at all, the empty
25560
+ // array/object a group falls back to while it has no child to aggregate — or a
25561
+ // shape whose every part is itself nothing. That last one is what a group with
25562
+ // an aggregate of its own produces before its children have arrived
25563
+ // (`{ mode: undefined, levels: [] }` has two keys and says nothing), and
25564
+ // reading it as an answer is how a popup opening empties the row above it.
25541
25565
  const uiStateHoldsNothing = (uiState) => {
25542
25566
  if (uiState === undefined) {
25543
25567
  return true;
25544
25568
  }
25545
25569
  if (Array.isArray(uiState)) {
25546
- return uiState.length === 0;
25570
+ return uiState.every(uiStateHoldsNothing);
25547
25571
  }
25548
25572
  if (uiState !== null && typeof uiState === "object") {
25549
- return Object.keys(uiState).length === 0;
25573
+ return Object.values(uiState).every(uiStateHoldsNothing);
25550
25574
  }
25551
25575
  return false;
25552
25576
  };
@@ -26832,7 +26856,8 @@ const useInteractiveProps = (props, {
26832
26856
  disabled,
26833
26857
  required,
26834
26858
  readOnly,
26835
- loading
26859
+ loading,
26860
+ optimistic
26836
26861
  } = props;
26837
26862
  const disabledResolved = disabled || controlDisabled;
26838
26863
  const requiredResolved = required || controlRequired;
@@ -26847,14 +26872,22 @@ const useInteractiveProps = (props, {
26847
26872
  // but cannot be taken.
26848
26873
  const readOnlyFromParentMaxLengthGuard = Boolean(uiStateController.parentUIStateController?.isChildBlockedByMaxLengthGuard?.(uiStateController));
26849
26874
  const readOnlyBase = readOnly || controlReadOnly || loadingBase || readOnlyFromParentMaxLengthGuard || controlInfo.readOnlyUncontrolled;
26850
- const loadingResolved = loadingBase || actionStatus.loading;
26851
- const readOnlyResolved = readOnlyBase || actionStatus.loading;
26875
+ // An optimistic control trusts its action to succeed: the state the user
26876
+ // just set stays visible and interactive while the action runs — no
26877
+ // loading, no readonly. On failure resetOnError rolls the state back and
26878
+ // the error callout says why.
26879
+ const actionLoading = optimistic ? false : actionStatus.loading;
26880
+ const loadingResolved = loadingBase || actionLoading;
26881
+ const readOnlyResolved = readOnlyBase || actionLoading;
26852
26882
  // Both halves of "busy" that do not come from the bound action, kept apart
26853
26883
  // from each other and from it: BUSY_CONSTRAINT answers each from its own
26854
26884
  // live source rather than from the rendered aria-busy, which conflates all
26855
26885
  // three and is a frame behind. See its own comment.
26856
26886
  uiStateController.loadingFromOwnProp = Boolean(loading);
26857
26887
  uiStateController.loadingFromParent = loadingFromParent;
26888
+ // Read by BUSY_CONSTRAINT: an optimistic control stays interactive while
26889
+ // its bound action runs (a new toggle replaces the run instead of waiting).
26890
+ uiStateController.optimistic = Boolean(optimistic);
26858
26891
  Object.assign(controlHostProps, {
26859
26892
  "required": requiredResolved,
26860
26893
  "aria-busy": loadingResolved ? "true" : "false",
@@ -27020,7 +27053,8 @@ const useInteractiveProps = (props, {
27020
27053
  onActionEnd,
27021
27054
  resetOnCancel,
27022
27055
  resetOnAbort,
27023
- resetOnError
27056
+ resetOnError,
27057
+ optimistic
27024
27058
  } = props;
27025
27059
  Object.assign(controlHostProps, {
27026
27060
  onFocus: e => {
@@ -27075,14 +27109,80 @@ const useInteractiveProps = (props, {
27075
27109
  // special case for the use case where form.requestSubmit is called
27076
27110
  e.detail.action = boundAction;
27077
27111
  }
27112
+ // An optimistic control stays interactive while its action runs, so a
27113
+ // second request can arrive mid-run. The server must receive them in
27114
+ // order: the new request is queued (latest wins — the bound action
27115
+ // reads the UI state signal at run time, so what goes out is always
27116
+ // the current state) and goes out once the running one has truly
27117
+ // settled (see onnavi_action_start). The running action is asked to
27118
+ // abort — its outcome is already outdated — but aborting is only a
27119
+ // resource optimization (a fetch wired to the action's signal gets
27120
+ // cancelled): the server may have done the work anyway, and only the
27121
+ // settlement says which. So even aborted, the underlying work is
27122
+ // awaited before the queued request runs. See the abort section in
27123
+ // docs/actions.md.
27124
+ if (optimistic && uiStateController.actionInFlight) {
27125
+ debugAction(e, `queueing action (one already in flight)`);
27126
+ uiStateController.queuedActionAllowedEvent = e;
27127
+ // The instance captured at navi_action_start, NOT boundAction:
27128
+ // boundAction is a proxy following the UI state signal, and the UI
27129
+ // state has already moved to the new value by now — the proxy would
27130
+ // resolve to the instance for that new value, which is not the one
27131
+ // running.
27132
+ uiStateController.runningAction?.abort(`superseded by a newer request on this control`);
27133
+ return;
27134
+ }
27078
27135
  debugAction(e, `executing action ${e.detail.action.callSource}`);
27079
27136
  executeAction(e);
27080
27137
  },
27081
27138
  onnavi_action_start: e => {
27139
+ // The run this control currently waits on, identified by the
27140
+ // navi_action_allowed event that launched it (unique per execution,
27141
+ // carried by every navi_action_* event of that run).
27142
+ uiStateController.pendingActionEvent = e.detail.event;
27143
+ uiStateController.actionInFlight = true;
27144
+ // The very instance this run uses, resolved now — while the UI state
27145
+ // still holds the value the run was made for. detail.action may be a
27146
+ // proxy following that state, and by the time anyone wants to abort
27147
+ // this run (see the optimistic queue above), the state — and the
27148
+ // proxy's resolution — will have moved on.
27149
+ const runAction = e.detail.action;
27150
+ uiStateController.runningAction = runAction.getCurrentAction?.() ?? runAction;
27151
+ // Fires when the run's underlying work has settled — even for an
27152
+ // aborted run, whose promise is awaited to completion (see
27153
+ // performRun in actions.js) — which is exactly what "the server is
27154
+ // done with it" means, and therefore when the queued request may go.
27155
+ e.detail.addSideEffect(outcome => {
27156
+ uiStateController.actionInFlight = false;
27157
+ uiStateController.runningAction = null;
27158
+ const queuedEvent = uiStateController.queuedActionAllowedEvent;
27159
+ uiStateController.queuedActionAllowedEvent = null;
27160
+ if (!queuedEvent) {
27161
+ return;
27162
+ }
27163
+ if (outcome.error) {
27164
+ // A failure abandons the queue: the UI is rolled back to the last
27165
+ // known state (resetOnError above), and what was queued was built
27166
+ // on top of the state that just failed.
27167
+ return;
27168
+ }
27169
+ // A microtask later, not right here: this runs inside the batch()
27170
+ // that settles the action (see watchActionCompletion for the same
27171
+ // constraint).
27172
+ queueMicrotask(() => {
27173
+ executeAction(queuedEvent);
27174
+ });
27175
+ });
27082
27176
  onActionStart?.(e);
27083
27177
  },
27084
27178
  onnavi_action_abort: e => {
27085
- if (resetOnAbort) {
27179
+ // Only an abort that leaves the control with nothing left to do may
27180
+ // reset the UI state. An abort whose run was superseded — a queued
27181
+ // request waits behind it (optimistic), or a newer run already
27182
+ // started — must leave the state alone: it belongs to the newer
27183
+ // request, resetting would throw away what the user just set.
27184
+ const superseded = Boolean(uiStateController.queuedActionAllowedEvent) || e.detail.event !== uiStateController.pendingActionEvent;
27185
+ if (resetOnAbort && !superseded) {
27086
27186
  dispatchRequestResetUIState(e.currentTarget, e);
27087
27187
  }
27088
27188
  onActionAborted?.(e);
@@ -27180,7 +27280,7 @@ const getAssociatedLabels = element => {
27180
27280
  return Array.from(element.labels);
27181
27281
  };
27182
27282
 
27183
- installImportMetaCssBuild(import.meta);const css$Y = /* css */`
27283
+ installImportMetaCssBuild(import.meta);const css$Z = /* css */`
27184
27284
  @layer navi {
27185
27285
  .navi_button {
27186
27286
  --button-border-radius: var(--navi-control-border-radius);
@@ -27611,7 +27711,7 @@ installImportMetaCssBuild(import.meta);const css$Y = /* css */`
27611
27711
  }
27612
27712
  `;
27613
27713
  const ButtonUI = props => {
27614
- import.meta.css = [css$Y, "@jsenv/navi/src/control/input/button_ui.jsx"];
27714
+ import.meta.css = [css$Z, "@jsenv/navi/src/control/input/button_ui.jsx"];
27615
27715
  const {
27616
27716
  ref,
27617
27717
  // href/link
@@ -29322,7 +29422,7 @@ installImportMetaCssBuild(import.meta);/**
29322
29422
  * reaches the real container.
29323
29423
  */
29324
29424
  let openLocalDialogCount = 0;
29325
- const css$X = /* css */`
29425
+ const css$Y = /* css */`
29326
29426
  @layer navi {
29327
29427
  .navi_dialog {
29328
29428
  /* Min gap between the dialog and the edges of its container. Written
@@ -29906,7 +30006,7 @@ const css$X = /* css */`
29906
30006
  * @param {import("ignore:preact").ComponentChildren} props.children
29907
30007
  */
29908
30008
  const Dialog = props => {
29909
- import.meta.css = [css$X, "@jsenv/navi/src/layout/dialog.jsx"];
30009
+ import.meta.css = [css$Y, "@jsenv/navi/src/layout/dialog.jsx"];
29910
30010
  if (props.openController) {
29911
30011
  return jsx(ControlledDialog, {
29912
30012
  ...props
@@ -30898,7 +30998,7 @@ installImportMetaCssBuild(import.meta);/**
30898
30998
  * and applied.
30899
30999
  */
30900
31000
  let openLocalPopoverCount = 0;
30901
- const css$W = /* css */`
31001
+ const css$X = /* css */`
30902
31002
  @layer navi {
30903
31003
  .navi_popover {
30904
31004
  /* soft: user-configurable preferred max-height. Kept as a *default*
@@ -31354,7 +31454,7 @@ const css$W = /* css */`
31354
31454
  * @param {import("ignore:preact").ComponentChildren} props.children
31355
31455
  */
31356
31456
  const Popover = props => {
31357
- import.meta.css = [css$W, "@jsenv/navi/src/layout/popover.jsx"];
31457
+ import.meta.css = [css$X, "@jsenv/navi/src/layout/popover.jsx"];
31358
31458
  if (props.openController) {
31359
31459
  return jsx(ControlledPopover, {
31360
31460
  ...props
@@ -32382,7 +32482,7 @@ installImportMetaCssBuild(import.meta);/**
32382
32482
  * event, and a caller replacing the body entirely then has one protocol to
32383
32483
  * follow — `--navi-confirm` for yes, anything that closes for no.
32384
32484
  */
32385
- const css$V = /* css */`
32485
+ const css$W = /* css */`
32386
32486
  /* The width lives on the body rather than on the popup, so that custom
32387
32487
  content (which replaces this body entirely) sizes itself instead of
32388
32488
  inheriting a ceiling meant for a sentence-long question. */
@@ -32519,7 +32619,7 @@ const ConfirmPopup = ({
32519
32619
  onAnswer,
32520
32620
  onClosed
32521
32621
  }) => {
32522
- import.meta.css = [css$V, "@jsenv/navi/src/action/confirm_popup.jsx"];
32622
+ import.meta.css = [css$W, "@jsenv/navi/src/action/confirm_popup.jsx"];
32523
32623
  const {
32524
32624
  mode,
32525
32625
  confirmLabel,
@@ -32603,7 +32703,7 @@ const defaultBody = (message, {
32603
32703
  });
32604
32704
  };
32605
32705
 
32606
- installImportMetaCssBuild(import.meta);const css$U = /* css */`
32706
+ installImportMetaCssBuild(import.meta);const css$V = /* css */`
32607
32707
  .action_error {
32608
32708
  margin-top: 0;
32609
32709
  margin-bottom: 20px;
@@ -32628,7 +32728,7 @@ const ActionRenderer = ({
32628
32728
  children,
32629
32729
  disabled
32630
32730
  }) => {
32631
- import.meta.css = [css$U, "@jsenv/navi/src/action/action_renderer.jsx"];
32731
+ import.meta.css = [css$V, "@jsenv/navi/src/action/action_renderer.jsx"];
32632
32732
  if (action === undefined) {
32633
32733
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
32634
32734
  }
@@ -38372,7 +38472,7 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
38372
38472
  // the root pictures must NOT move (they carry the whole viewport, blank bands
38373
38473
  // included).
38374
38474
 
38375
- const css$T = /* css */`
38475
+ const css$U = /* css */`
38376
38476
  /* The marked region is a picture of its own for the length of a transition of
38377
38477
  OURS, and only then — the name is what makes the pages a picture the
38378
38478
  movement below can carry.
@@ -38801,7 +38901,7 @@ const RouteTransitionArea = ({
38801
38901
  children,
38802
38902
  ...rest
38803
38903
  }) => {
38804
- import.meta.css = [css$T, "@jsenv/navi/src/nav/route_transition.jsx"];
38904
+ import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
38805
38905
  const props = {
38806
38906
  ...rest,
38807
38907
  [TRANSITION_AREA_ATTRIBUTE]: ""
@@ -38858,7 +38958,7 @@ const RouteTransitionArea = ({
38858
38958
  * @returns {() => void} remove this relation.
38859
38959
  */
38860
38960
  const defineRouteTransition = (from, to, transition) => {
38861
- import.meta.css = [css$T, "@jsenv/navi/src/nav/route_transition.jsx"];
38961
+ import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
38862
38962
  const {
38863
38963
  type,
38864
38964
  duration
@@ -38894,7 +38994,7 @@ const defineRouteTransition = (from, to, transition) => {
38894
38994
  * @returns {() => void} remove this default.
38895
38995
  */
38896
38996
  const defineRouteDefaultTransition = transition => {
38897
- import.meta.css = [css$T, "@jsenv/navi/src/nav/route_transition.jsx"];
38997
+ import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
38898
38998
  const value = normalizeTransition(transition);
38899
38999
  defaultTransition = value;
38900
39000
  return () => {
@@ -39495,7 +39595,7 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
39495
39595
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
39496
39596
  // The name the box wears while it travels, and only then (see nameForTravel).
39497
39597
  const TRAVEL_NAME = "navi-route-travel";
39498
- const css$S = /* css */`
39598
+ const css$T = /* css */`
39499
39599
  /* The name that makes the page inside this box a picture of its own during a
39500
39600
  transition — rather than part of the one big picture the document takes, so
39501
39601
  the two pages can move past each other while everything else stays where it
@@ -39885,7 +39985,7 @@ const RouteTravel = ({
39885
39985
  children,
39886
39986
  ...rest
39887
39987
  }) => {
39888
- import.meta.css = [css$S, "@jsenv/navi/src/nav/route_travel.jsx"];
39988
+ import.meta.css = [css$T, "@jsenv/navi/src/nav/route_travel.jsx"];
39889
39989
  const elementRef = useRef();
39890
39990
  const gestureRef = useRef(null);
39891
39991
  // The travel in hand: the transition keeping the picture of the page being
@@ -42447,7 +42547,7 @@ const PhoneSvg = () => {
42447
42547
  };
42448
42548
 
42449
42549
  installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
42450
- const css$R = /* css */`
42550
+ const css$S = /* css */`
42451
42551
  .navi_text_anchor {
42452
42552
  vertical-align: baseline;
42453
42553
  user-select: none;
@@ -42482,7 +42582,7 @@ const TextAnchor = ({
42482
42582
  textSize,
42483
42583
  lineLayout
42484
42584
  }) => {
42485
- import.meta.css = [css$R, "@jsenv/navi/src/text/text_anchor.jsx"];
42585
+ import.meta.css = [css$S, "@jsenv/navi/src/text/text_anchor.jsx"];
42486
42586
  const anchorRef = useRef();
42487
42587
 
42488
42588
  // Plain useLayoutEffect would also fire while an ancestor dialog/popover
@@ -42597,7 +42697,7 @@ const computeTopOffset = ({
42597
42697
  };
42598
42698
  const charTopCanvas = document.createElement("canvas");
42599
42699
 
42600
- installImportMetaCssBuild(import.meta);const css$Q = /* css */`
42700
+ installImportMetaCssBuild(import.meta);const css$R = /* css */`
42601
42701
  @layer navi {
42602
42702
  /* Ensure data attributes from box.jsx can win to update display */
42603
42703
  .navi_icon {
@@ -42755,7 +42855,7 @@ const Icon = ({
42755
42855
  fillLine,
42756
42856
  ...props
42757
42857
  }) => {
42758
- import.meta.css = [css$Q, "@jsenv/navi/src/text/icon.jsx"];
42858
+ import.meta.css = [css$R, "@jsenv/navi/src/text/icon.jsx"];
42759
42859
  const innerChildren = href ? jsx("svg", {
42760
42860
  width: "100%",
42761
42861
  height: "100%",
@@ -42917,7 +43017,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
42917
43017
  });
42918
43018
  };
42919
43019
 
42920
- installImportMetaCssBuild(import.meta);const css$P = /* css */`
43020
+ installImportMetaCssBuild(import.meta);const css$Q = /* css */`
42921
43021
  @layer navi {
42922
43022
  .navi_link {
42923
43023
  --link-border-radius: unset;
@@ -43379,7 +43479,7 @@ Object.assign(PSEUDO_CLASSES, {
43379
43479
  * @param {boolean} [props.readOnly]
43380
43480
  */
43381
43481
  const Link = props => {
43382
- import.meta.css = [css$P, "@jsenv/navi/src/nav/link/link.jsx"];
43482
+ import.meta.css = [css$Q, "@jsenv/navi/src/nav/link/link.jsx"];
43383
43483
  if (props.route) {
43384
43484
  return jsx(LinkWithRoute, {
43385
43485
  ...props
@@ -43702,7 +43802,7 @@ installImportMetaCssBuild(import.meta);/**
43702
43802
  * https://dribbble.com/search/tabs
43703
43803
  */
43704
43804
  let navCount = 0;
43705
- const css$O = /* css */`
43805
+ const css$P = /* css */`
43706
43806
  @layer navi {
43707
43807
  .navi_nav {
43708
43808
  --nav-border: none;
@@ -43985,7 +44085,7 @@ const Nav = ({
43985
44085
  slideContainer,
43986
44086
  ...props
43987
44087
  }) => {
43988
- import.meta.css = [css$O, "@jsenv/navi/src/nav/link/nav.jsx"];
44088
+ import.meta.css = [css$P, "@jsenv/navi/src/nav/link/nav.jsx"];
43989
44089
  const defaultRef = useRef();
43990
44090
  props.ref = props.ref || defaultRef;
43991
44091
  const navRef = props.ref;
@@ -44512,7 +44612,7 @@ installImportMetaCssBuild(import.meta);/**
44512
44612
  * Border width participates in layout (it is added to the tab and page
44513
44613
  * padding): a thick border grows the binder rather than eating into the text.
44514
44614
  */
44515
- const css$N = /* css */`
44615
+ const css$O = /* css */`
44516
44616
  @layer navi {
44517
44617
  .navi_binder {
44518
44618
  --binder-border-width: var(--navi-control-border-width);
@@ -44825,7 +44925,7 @@ const Binder = ({
44825
44925
  pagePadding,
44826
44926
  ...props
44827
44927
  }) => {
44828
- import.meta.css = [css$N, "@jsenv/navi/src/nav/binder/binder.jsx"];
44928
+ import.meta.css = [css$O, "@jsenv/navi/src/nav/binder/binder.jsx"];
44829
44929
  const items = toChildArray(children).map((child, index) => {
44830
44930
  const {
44831
44931
  value: itemValue,
@@ -45312,7 +45412,7 @@ installImportMetaCssBuild(import.meta);/**
45312
45412
  * added to the size asked for exactly like the notch inset is, so the
45313
45413
  * content still gets the size the prop names.
45314
45414
  */
45315
- const css$M = /* css */`
45415
+ const css$N = /* css */`
45316
45416
  @layer navi {
45317
45417
  :root {
45318
45418
  --navi-fixed-bar-width: 56px;
@@ -45460,7 +45560,7 @@ const FixedBar = ({
45460
45560
  border = true,
45461
45561
  ...props
45462
45562
  }) => {
45463
- import.meta.css = [css$M, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
45563
+ import.meta.css = [css$N, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
45464
45564
  const defaultRef = useRef();
45465
45565
  props.ref = props.ref || defaultRef;
45466
45566
  // Said with the width the border rule reads rather than with an attribute of
@@ -45554,7 +45654,7 @@ const FixedBar = ({
45554
45654
  // Subpixel layout rounds rectangles up on boxes that fit exactly.
45555
45655
  const OVERFLOW_TOLERANCE = 1;
45556
45656
 
45557
- const css$L = /* css */ `
45657
+ const css$M = /* css */ `
45558
45658
  [data-navi-overflow-x] {
45559
45659
  outline: 2px dashed #e74c3c;
45560
45660
  outline-offset: -2px;
@@ -45578,7 +45678,7 @@ const detectHorizontalOverflow = ({
45578
45678
  let styleEl = null;
45579
45679
  if (highlight) {
45580
45680
  styleEl = document.createElement("style");
45581
- styleEl.textContent = css$L;
45681
+ styleEl.textContent = css$M;
45582
45682
  document.head.appendChild(styleEl);
45583
45683
  }
45584
45684
 
@@ -45734,7 +45834,7 @@ const useFocusGroup = (
45734
45834
 
45735
45835
  installImportMetaCssBuild(import.meta);const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
45736
45836
  const downArrowPath = "M480-280L160-600l80-80 240 240 240-240 80 80-320 320z";
45737
- const css$K = /* css */`
45837
+ const css$L = /* css */`
45738
45838
  .navi_summary_marker {
45739
45839
  width: 1em;
45740
45840
  height: 1em;
@@ -45819,7 +45919,7 @@ const SummaryMarker = ({
45819
45919
  open,
45820
45920
  loading
45821
45921
  }) => {
45822
- import.meta.css = [css$K, "@jsenv/navi/src/control/details/summary_marker.jsx"];
45922
+ import.meta.css = [css$L, "@jsenv/navi/src/control/details/summary_marker.jsx"];
45823
45923
  const showLoading = useDebounceTrue(loading, 300);
45824
45924
  const mountedRef = useRef(false);
45825
45925
  const prevOpenRef = useRef(open);
@@ -45873,7 +45973,7 @@ const SummaryMarker = ({
45873
45973
  });
45874
45974
  };
45875
45975
 
45876
- installImportMetaCssBuild(import.meta);const css$J = /* css */`
45976
+ installImportMetaCssBuild(import.meta);const css$K = /* css */`
45877
45977
  .navi_details {
45878
45978
  position: relative;
45879
45979
  z-index: 1;
@@ -45919,7 +46019,7 @@ const Details = props => {
45919
46019
  return details;
45920
46020
  };
45921
46021
  const DetailsField = props => {
45922
- import.meta.css = [css$J, "@jsenv/navi/src/control/details/details.jsx"];
46022
+ import.meta.css = [css$K, "@jsenv/navi/src/control/details/details.jsx"];
45923
46023
  const {
45924
46024
  ref,
45925
46025
  persists,
@@ -46075,12 +46175,647 @@ const DetailsFieldContent = ({
46075
46175
  children
46076
46176
  }) => {
46077
46177
  const action = useContext(ActionContext);
46178
+ if (!action) {
46179
+ return children;
46180
+ }
46078
46181
  return jsx(ActionRenderer, {
46079
46182
  action: action,
46080
46183
  children: children
46081
46184
  });
46082
46185
  };
46083
46186
 
46187
+ installImportMetaCssBuild(import.meta);/**
46188
+ * Expandable: an in-flow disclosure — a UI part that reveals a content part.
46189
+ * It covers the same ground as <Details> with structural differences:
46190
+ *
46191
+ * - the two parts are explicit and free to order/orient:
46192
+ *
46193
+ * <Expandable>
46194
+ * <Expandable.UI>See more</Expandable.UI>
46195
+ * <Expandable.Content>…</Expandable.Content>
46196
+ * </Expandable>
46197
+ *
46198
+ * Content after UI expands below (the <details> shape), Content before UI
46199
+ * expands above; `layout="column"` puts the parts side by side instead, the
46200
+ * content then expanding horizontally. The common shape has a shorthand:
46201
+ * `ui` prop + children as content.
46202
+ * - the UI part accepts any markup (buttons, links, fields) — only the small
46203
+ * marker is a real <button>, carrying the aria for the whole row
46204
+ * (aria-expanded/aria-controls, labelled by the UI part), so nested
46205
+ * interactive content never ends up inside an interactive element.
46206
+ *
46207
+ * What <details> gives for free is rebuilt here:
46208
+ * - a "toggle" event (a real ToggleEvent when the browser has it) dispatched on
46209
+ * the root whenever the state actually changes — but never on mount, unlike
46210
+ * the native one (see the workaround comment in details.jsx);
46211
+ * - `--navi-toggle`/`--navi-open`/`--navi-close` commands work against it: the
46212
+ * root carries `aria-expanded` (what the command system reads) and answers
46213
+ * the `navi_command`/`navi_request_open`/`navi_request_close` events.
46214
+ *
46215
+ * Content is not built until the first expansion and stays built afterwards —
46216
+ * same policy, same prop names as popups (see popup_content_mount.js):
46217
+ * `mountWhenClosed` builds it right away, `unmountWhenClosed` throws it away
46218
+ * once the collapse settles (so a closing animation still plays on real
46219
+ * content).
46220
+ *
46221
+ * The expansion animates the content's grid track (0fr <-> 1fr — rows for the
46222
+ * stacked layout, columns for `layout="column"`) rather than `height`/`width`:
46223
+ * the open size is "auto" (content-sized), which a length transition cannot
46224
+ * interpolate to, while a fr track can. The content is clipped only while the
46225
+ * track moves; once settled open it overflows normally again, so a popover or
46226
+ * focus ring inside is not cut at the edges.
46227
+ */
46228
+ const css$J = /* css */`
46229
+ .navi_expandable {
46230
+ position: relative;
46231
+ display: flex;
46232
+ flex-shrink: 0;
46233
+ flex-direction: column;
46234
+
46235
+ > .navi_expandable_ui {
46236
+ display: flex;
46237
+ flex-shrink: 0;
46238
+ flex-direction: row;
46239
+ align-items: center;
46240
+ gap: 0.2em;
46241
+ cursor: pointer;
46242
+ user-select: none;
46243
+
46244
+ /* The whole row shows the focus, like a native <summary>, even though
46245
+ only the marker button actually holds it. */
46246
+ &:has(> .navi_expandable_toggle:focus-visible) {
46247
+ border-radius: 4px;
46248
+ outline: 2px solid AccentColor;
46249
+ outline-offset: 1px;
46250
+ }
46251
+
46252
+ > .navi_expandable_toggle {
46253
+ display: flex;
46254
+ padding: 0;
46255
+ align-items: center;
46256
+ color: inherit;
46257
+ font: inherit;
46258
+ background: none;
46259
+ border: none;
46260
+ cursor: pointer;
46261
+
46262
+ &:focus-visible {
46263
+ outline: none;
46264
+ }
46265
+ }
46266
+
46267
+ > .navi_expandable_ui_label {
46268
+ display: flex;
46269
+ flex: 1;
46270
+ align-items: center;
46271
+ gap: 0.2em;
46272
+ }
46273
+ }
46274
+
46275
+ > .navi_expandable_content_container {
46276
+ display: grid;
46277
+ grid-template-rows: 0fr;
46278
+
46279
+ > .navi_expandable_content {
46280
+ min-height: 0;
46281
+ overflow: hidden;
46282
+ }
46283
+ }
46284
+ &[aria-expanded="true"] > .navi_expandable_content_container {
46285
+ grid-template-rows: 1fr;
46286
+ }
46287
+
46288
+ /* The parts sit side by side: the UI part becomes a vertical strip and
46289
+ the content expands horizontally, on the columns track. */
46290
+ &[data-layout="column"] {
46291
+ flex-direction: row;
46292
+
46293
+ > .navi_expandable_ui {
46294
+ flex-direction: column;
46295
+
46296
+ > .navi_expandable_ui_label {
46297
+ flex-direction: column;
46298
+ }
46299
+ }
46300
+ > .navi_expandable_content_container {
46301
+ grid-template-columns: 0fr;
46302
+ grid-template-rows: none;
46303
+
46304
+ > .navi_expandable_content {
46305
+ min-width: 0;
46306
+ min-height: auto;
46307
+ }
46308
+ }
46309
+ &[aria-expanded="true"] > .navi_expandable_content_container {
46310
+ grid-template-columns: 1fr;
46311
+ grid-template-rows: none;
46312
+ }
46313
+ }
46314
+
46315
+ &[data-animation] > .navi_expandable_content_container {
46316
+ transition:
46317
+ grid-template-rows var(--navi-expandable-animation-duration, 0.3s) ease,
46318
+ grid-template-columns var(--navi-expandable-animation-duration, 0.3s)
46319
+ ease;
46320
+ }
46321
+ @media (prefers-reduced-motion: reduce) {
46322
+ &[data-animation] > .navi_expandable_content_container {
46323
+ transition: none;
46324
+ }
46325
+ }
46326
+ /* Settled open: stop clipping, so a popover, a focus ring or a dragged
46327
+ element inside the content can spill out — unless the content is given
46328
+ a max height, where the clipping IS the feature (it scrolls). */
46329
+ &[aria-expanded="true"][data-settled]:not([data-content-scrolls])
46330
+ > .navi_expandable_content_container
46331
+ > .navi_expandable_content {
46332
+ overflow: visible;
46333
+ }
46334
+ &[data-content-scrolls]
46335
+ > .navi_expandable_content_container
46336
+ > .navi_expandable_content {
46337
+ max-height: var(--navi-expandable-max-content-height);
46338
+ overflow-y: auto;
46339
+ }
46340
+ }
46341
+ `;
46342
+ const ExpandableContext = createContext(null);
46343
+ const useExpandableContext = partName => {
46344
+ const expandableContext = useContext(ExpandableContext);
46345
+ if (!expandableContext) {
46346
+ throw new Error(`<Expandable.${partName}> must be used inside <Expandable>`);
46347
+ }
46348
+ return expandableContext;
46349
+ };
46350
+
46351
+ /**
46352
+ * @type {import("ignore:preact").FunctionComponent<{
46353
+ * ui?: import("ignore:preact").ComponentChildren | ((state: { open: boolean }) => import("ignore:preact").ComponentChildren),
46354
+ * open?: boolean,
46355
+ * defaultOpen?: boolean,
46356
+ * signal?: import("@preact/signals").Signal<boolean>,
46357
+ * onToggle?: (event: Event) => void,
46358
+ * action?: Function,
46359
+ * loading?: boolean,
46360
+ * animation?: boolean,
46361
+ * layout?: "row" | "column",
46362
+ * autoFocus?: boolean,
46363
+ * maxContentHeight?: string | number,
46364
+ * mountWhenClosed?: boolean,
46365
+ * unmountWhenClosed?: boolean,
46366
+ * arrowKeyShortcuts?: boolean,
46367
+ * openKeyShortcut?: string,
46368
+ * closeKeyShortcut?: string,
46369
+ * }>}
46370
+ * @param ui - Shorthand for the common shape: renders `<Expandable.UI>{ui}</Expandable.UI>`
46371
+ * above the content (children). Any markup is allowed (buttons, links,
46372
+ * fields inside it keep their own behavior and do not toggle the
46373
+ * expandable). A function receives `{ open }` to render differently per
46374
+ * state. For other orders/orientations, pass `<Expandable.UI>` and
46375
+ * `<Expandable.Content>` as children instead.
46376
+ * @param open - Drives the state from outside: the expandable opens/closes to
46377
+ * match every change of this prop, but user interaction can still toggle it
46378
+ * in between (same semantics as Dialog/Popover's own `open`).
46379
+ * @param defaultOpen - Uncontrolled, mount-only initial state.
46380
+ * @param signal - Two-way binding: the expandable follows the signal and
46381
+ * writes back into it whenever it toggles on its own. Excludes `open`.
46382
+ * @param onToggle - Listens the "toggle" event dispatched on the root (a
46383
+ * ToggleEvent with newState/oldState where supported). Fires on every actual
46384
+ * state change, never on mount.
46385
+ * @param action - Ran when the expandable opens, aborted when it closes.
46386
+ * Content children may then be a function `(data) => ui` or a branches
46387
+ * object (`{ loading, error, completed, ... }`) — see ActionRenderer.
46388
+ * @param loading - Shows the loading spinner on the marker regardless of
46389
+ * `action`'s own loading state.
46390
+ * @param animation - Off by default. `true` plays the expand/collapse track
46391
+ * transition; duration comes from `--navi-expandable-animation-duration`
46392
+ * (0.3s).
46393
+ * @param layout - `"row"` (default): the parts stack, the content expands
46394
+ * vertically. `"column"`: the parts sit side by side, the content expands
46395
+ * horizontally next to the UI part.
46396
+ * @param autoFocus - Off by default (the focus stays on the marker when
46397
+ * opening). `true` moves the focus into the content on open — the
46398
+ * `[autofocus]` element if any, the first focusable otherwise. Whatever the
46399
+ * setting, closing while the focus is inside the content hands it back to
46400
+ * the marker (it would otherwise be lost to the closed, inert content).
46401
+ * @param maxContentHeight - Caps the content height; taller content scrolls
46402
+ * inside the expandable instead of growing it.
46403
+ * @param mountWhenClosed - Builds the content right away instead of on first
46404
+ * expansion.
46405
+ * @param unmountWhenClosed - Throws the content away once the collapse
46406
+ * settles — after the closing animation, so it still plays on real content —
46407
+ * and rebuilds it from scratch on every expansion.
46408
+ */
46409
+ const Expandable = props => {
46410
+ import.meta.css = [css$J, "@jsenv/navi/src/control/expandable/expandable.jsx"];
46411
+ const {
46412
+ ref,
46413
+ ui,
46414
+ open,
46415
+ defaultOpen,
46416
+ signal,
46417
+ action,
46418
+ loading,
46419
+ animation = false,
46420
+ layout,
46421
+ autoFocus,
46422
+ maxContentHeight,
46423
+ mountWhenClosed,
46424
+ unmountWhenClosed,
46425
+ arrowKeyShortcuts = true,
46426
+ openKeyShortcut = "ArrowRight",
46427
+ closeKeyShortcut = "ArrowLeft",
46428
+ children,
46429
+ ...rest
46430
+ } = props;
46431
+ const defaultRef = useRef();
46432
+ const rootRef = ref || defaultRef;
46433
+ const uiRef = useRef();
46434
+ const toggleButtonRef = useRef();
46435
+ const contentContainerRef = useRef();
46436
+ const contentId = useId();
46437
+ const uiId = useId();
46438
+ // Reading .value during render is what subscribes the expandable to it.
46439
+ const openRequested = signal ? signal.value : open;
46440
+ const [opened, setOpened] = useState(() => Boolean(openRequested === undefined ? defaultOpen : openRequested));
46441
+ const openedRef = useRef(opened);
46442
+ openedRef.current = opened;
46443
+ const hasAction = Boolean(action);
46444
+ const effectiveAction = useAction(action);
46445
+ const {
46446
+ loading: actionLoading
46447
+ } = useActionStatus(effectiveAction);
46448
+ const [contentMounted, setContentMounted] = useState(() => Boolean(mountWhenClosed) || opened);
46449
+ // Same exclusion as popup_content_mount.js: content that must exist while
46450
+ // closed cannot also be thrown away on close.
46451
+ const effectiveUnmountWhenClosed = unmountWhenClosed && !mountWhenClosed;
46452
+
46453
+ // Fully open and no longer moving — what allows overflow to become visible
46454
+ // (see the CSS) and what unmountWhenClosed waits for before emptying.
46455
+ const [settled, setSettled] = useState(true);
46456
+
46457
+ // Read before the close touches the DOM: flipping the content to inert can
46458
+ // blur what it held, so by effect time the focus to hand back to the marker
46459
+ // could already be gone.
46460
+ const focusedBeforeCloseRef = useRef(null);
46461
+ // The pointer press that is about to toggle blurs the focused field before
46462
+ // the click ever fires (pressing a non-focusable row moves the focus to
46463
+ // body) — so what held the focus has to be remembered at pointerdown time.
46464
+ const focusedAtPointerDownRef = useRef(null);
46465
+ const onUIPointerDown = () => {
46466
+ focusedAtPointerDownRef.current = document.activeElement;
46467
+ };
46468
+ const toggleTo = nextOpen => {
46469
+ nextOpen = Boolean(nextOpen);
46470
+ if (nextOpen === openedRef.current) {
46471
+ return;
46472
+ }
46473
+ openedRef.current = nextOpen;
46474
+ if (nextOpen) {
46475
+ setContentMounted(true);
46476
+ } else {
46477
+ const activeElement = document.activeElement;
46478
+ focusedBeforeCloseRef.current = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
46479
+ }
46480
+ focusedAtPointerDownRef.current = null;
46481
+ setOpened(nextOpen);
46482
+ // Flipped here, before the closing/opening commit, so effects of that very
46483
+ // commit already see the movement as started — unmountWhenClosed must not
46484
+ // read a stale "settled" and empty the content under a closing animation.
46485
+ setSettled(!animation);
46486
+ if (signal) {
46487
+ signal.value = nextOpen;
46488
+ }
46489
+ if (hasAction) {
46490
+ if (nextOpen) {
46491
+ effectiveAction.run();
46492
+ } else {
46493
+ effectiveAction.abort();
46494
+ }
46495
+ }
46496
+ };
46497
+ const findFirstFocusableInContent = () => {
46498
+ const contentContainer = contentContainerRef.current;
46499
+ if (!contentContainer) {
46500
+ return null;
46501
+ }
46502
+ const autofocusElement = contentContainer.querySelector("[autofocus]");
46503
+ if (autofocusElement) {
46504
+ return autofocusElement;
46505
+ }
46506
+ return findAfter(contentContainer, elementIsFocusable, {
46507
+ root: contentContainer
46508
+ });
46509
+ };
46510
+
46511
+ // Follow `open`/`signal` changes after mount (the initial value is already
46512
+ // in the state above). A self-initiated toggle that wrote the signal lands
46513
+ // here too and no-ops, since the state already matches.
46514
+ const isFirstOpenRequestedRunRef = useRef(true);
46515
+ useLayoutEffect(() => {
46516
+ if (isFirstOpenRequestedRunRef.current) {
46517
+ isFirstOpenRequestedRunRef.current = false;
46518
+ return;
46519
+ }
46520
+ if (openRequested === undefined) {
46521
+ return;
46522
+ }
46523
+ toggleTo(openRequested);
46524
+ }, [openRequested]);
46525
+
46526
+ // A state change: tell the world (the "toggle" event), move the focus, and
46527
+ // follow the transition to know when the movement is over. Skipped on mount —
46528
+ // nothing changed, so neither the event nor a transition exists (and a page
46529
+ // must not have its focus stolen by an expandable that was simply already
46530
+ // open).
46531
+ const isFirstOpenedRunRef = useRef(true);
46532
+ useLayoutEffect(() => {
46533
+ if (isFirstOpenedRunRef.current) {
46534
+ isFirstOpenedRunRef.current = false;
46535
+ return undefined;
46536
+ }
46537
+ const root = rootRef.current;
46538
+ root.dispatchEvent(createToggleEvent(opened));
46539
+ if (opened) {
46540
+ if (autoFocus) {
46541
+ const firstFocusableElement = findFirstFocusableInContent();
46542
+ if (firstFocusableElement) {
46543
+ firstFocusableElement.focus();
46544
+ }
46545
+ }
46546
+ } else {
46547
+ const focusedBeforeClose = focusedBeforeCloseRef.current;
46548
+ focusedBeforeCloseRef.current = null;
46549
+ if (focusedBeforeClose && contentContainerRef.current && contentContainerRef.current.contains(focusedBeforeClose)) {
46550
+ toggleButtonRef.current.focus();
46551
+ }
46552
+ }
46553
+ if (!animation) {
46554
+ return undefined;
46555
+ }
46556
+ const cancel = whenTransitionSettles(contentContainerRef.current, () => {
46557
+ setSettled(true);
46558
+ });
46559
+ return cancel;
46560
+ }, [opened]);
46561
+ useLayoutEffect(() => {
46562
+ if (settled && !opened && effectiveUnmountWhenClosed) {
46563
+ setContentMounted(false);
46564
+ }
46565
+ }, [settled, opened, effectiveUnmountWhenClosed]);
46566
+ useLayoutEffect(() => {
46567
+ if (mountWhenClosed) {
46568
+ setContentMounted(true);
46569
+ }
46570
+ }, [mountWhenClosed]);
46571
+
46572
+ // Mounted already open: the content is visible, its data is due.
46573
+ useEffect(() => {
46574
+ if (openedRef.current && hasAction) {
46575
+ effectiveAction.run();
46576
+ }
46577
+ }, []);
46578
+ const onRootKeyDown = keyboardEvent => {
46579
+ if (!arrowKeyShortcuts) {
46580
+ return;
46581
+ }
46582
+ // A nested expandable (deeper, so heard first) already answered this key.
46583
+ if (keyboardEvent.defaultPrevented) {
46584
+ return;
46585
+ }
46586
+ // Leave the key to whatever native use its target has for it (moving the
46587
+ // caret in a field, changing a slider) — only a press with nothing else
46588
+ // to do drives the expandable.
46589
+ const defaultAction = getKeyboardEventDefaultAction(keyboardEvent);
46590
+ if (defaultAction && defaultAction !== "scroll") {
46591
+ return;
46592
+ }
46593
+ const {
46594
+ key
46595
+ } = keyboardEvent;
46596
+ if (key === openKeyShortcut) {
46597
+ if (document.activeElement !== toggleButtonRef.current) {
46598
+ return;
46599
+ }
46600
+ if (!openedRef.current) {
46601
+ keyboardEvent.preventDefault();
46602
+ toggleTo(true);
46603
+ return;
46604
+ }
46605
+ const firstFocusableElementInContent = findFirstFocusableInContent();
46606
+ if (!firstFocusableElementInContent) {
46607
+ return;
46608
+ }
46609
+ keyboardEvent.preventDefault();
46610
+ firstFocusableElementInContent.focus();
46611
+ return;
46612
+ }
46613
+ if (key === closeKeyShortcut) {
46614
+ if (!openedRef.current) {
46615
+ return;
46616
+ }
46617
+ const toggleButton = toggleButtonRef.current;
46618
+ if (document.activeElement === toggleButton) {
46619
+ keyboardEvent.preventDefault();
46620
+ toggleTo(false);
46621
+ } else {
46622
+ keyboardEvent.preventDefault();
46623
+ toggleButton.focus();
46624
+ }
46625
+ }
46626
+ };
46627
+ const onUIClick = clickEvent => {
46628
+ // A navi control inside the UI part cancels the click it consumed (see
46629
+ // click_to_expand.js — the root's aria-expanded is what it finds).
46630
+ if (clickEvent.defaultPrevented) {
46631
+ return;
46632
+ }
46633
+ const {
46634
+ target
46635
+ } = clickEvent;
46636
+ if (target.nodeType === 1) {
46637
+ const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
46638
+ if (interactiveElement && interactiveElement !== toggleButtonRef.current && uiRef.current.contains(interactiveElement)) {
46639
+ return;
46640
+ }
46641
+ }
46642
+ toggleTo(!openedRef.current);
46643
+ };
46644
+ const expandableContextValue = {
46645
+ opened,
46646
+ loading: loading || hasAction && actionLoading,
46647
+ contentMounted,
46648
+ hasAction,
46649
+ effectiveAction,
46650
+ onUIClick,
46651
+ onUIPointerDown,
46652
+ uiRef,
46653
+ toggleButtonRef,
46654
+ contentContainerRef,
46655
+ contentId,
46656
+ uiId
46657
+ };
46658
+
46659
+ // Explicit parts win; the `ui` prop + children is the shorthand for the
46660
+ // common shape (UI above, content below).
46661
+ const childArray = toChildArray(children);
46662
+ const hasParts = childArray.some(child => child && (child.type === ExpandableUI || child.type === ExpandableContent));
46663
+ const body = hasParts ? children : jsxs(Fragment$1, {
46664
+ children: [jsx(ExpandableUI, {
46665
+ children: ui
46666
+ }), jsx(ExpandableContent, {
46667
+ children: children
46668
+ })]
46669
+ });
46670
+ return jsx(Box, {
46671
+ ref: rootRef,
46672
+ baseClassName: "navi_expandable",
46673
+ "aria-expanded": opened ? "true" : "false",
46674
+ "data-layout": layout === "column" ? "column" : undefined,
46675
+ "data-animation": animation ? "" : undefined,
46676
+ "data-settled": settled ? "" : undefined,
46677
+ "data-content-scrolls": maxContentHeight === undefined ? undefined : "",
46678
+ ...rest,
46679
+ // The protocol every command target answers (see commands.js): a
46680
+ // `--navi-toggle`/`--navi-open`/`--navi-close` lands here as a
46681
+ // navi_command whose implementation dispatches the request events below.
46682
+ onnavi_command: e => {
46683
+ rest.onnavi_command?.(e);
46684
+ onNaviCommand(e);
46685
+ },
46686
+ onnavi_request_open: e => {
46687
+ rest.onnavi_request_open?.(e);
46688
+ toggleTo(true);
46689
+ },
46690
+ onnavi_request_close: e => {
46691
+ rest.onnavi_request_close?.(e);
46692
+ toggleTo(false);
46693
+ },
46694
+ onKeyDown: e => {
46695
+ rest.onKeyDown?.(e);
46696
+ onRootKeyDown(e);
46697
+ },
46698
+ style: maxContentHeight === undefined ? rest.style : {
46699
+ "--navi-expandable-max-content-height": stringifyStyle$1(maxContentHeight, "maxHeight"),
46700
+ ...rest.style
46701
+ },
46702
+ children: jsx(ExpandableContext.Provider, {
46703
+ value: expandableContextValue,
46704
+ children: body
46705
+ })
46706
+ });
46707
+ };
46708
+
46709
+ /**
46710
+ * The always-visible part that reveals the content: the marker button plus
46711
+ * whatever it is given — any markup, a function of `{ open }` included. Its
46712
+ * position among the parts decides where the content goes (before the content:
46713
+ * content below/right; after it: content above/left).
46714
+ *
46715
+ * @type {import("ignore:preact").FunctionComponent<{
46716
+ * children?: import("ignore:preact").ComponentChildren | ((state: { open: boolean }) => import("ignore:preact").ComponentChildren),
46717
+ * }>}
46718
+ */
46719
+ const ExpandableUI = ({
46720
+ children,
46721
+ ...rest
46722
+ }) => {
46723
+ const {
46724
+ opened,
46725
+ loading,
46726
+ onUIClick,
46727
+ onUIPointerDown,
46728
+ uiRef,
46729
+ toggleButtonRef,
46730
+ contentId,
46731
+ uiId
46732
+ } = useExpandableContext("UI");
46733
+ return jsxs("div", {
46734
+ ref: uiRef,
46735
+ className: "navi_expandable_ui",
46736
+ onClick: onUIClick,
46737
+ onPointerDown: onUIPointerDown,
46738
+ ...rest,
46739
+ children: [jsx("button", {
46740
+ ref: toggleButtonRef,
46741
+ type: "button",
46742
+ className: "navi_expandable_toggle",
46743
+ "aria-expanded": opened,
46744
+ "aria-controls": contentId,
46745
+ "aria-labelledby": uiId,
46746
+ children: jsx(SummaryMarker, {
46747
+ open: opened,
46748
+ loading: loading
46749
+ })
46750
+ }), jsx("div", {
46751
+ id: uiId,
46752
+ className: "navi_expandable_ui_label",
46753
+ children: typeof children === "function" ? children({
46754
+ open: opened
46755
+ }) : children
46756
+ })]
46757
+ });
46758
+ };
46759
+
46760
+ /**
46761
+ * The revealed part. With an `action` on the Expandable, children may be a
46762
+ * function `(data) => ui` or a branches object — see ActionRenderer.
46763
+ *
46764
+ * @type {import("ignore:preact").FunctionComponent<{}>}
46765
+ */
46766
+ const ExpandableContent = ({
46767
+ children,
46768
+ ...rest
46769
+ }) => {
46770
+ const {
46771
+ opened,
46772
+ contentMounted,
46773
+ hasAction,
46774
+ effectiveAction,
46775
+ contentContainerRef,
46776
+ contentId
46777
+ } = useExpandableContext("Content");
46778
+ let content = children;
46779
+ if (hasAction) {
46780
+ content = jsx(ActionRenderer, {
46781
+ action: effectiveAction,
46782
+ children: children
46783
+ });
46784
+ }
46785
+ return jsx("div", {
46786
+ ref: contentContainerRef,
46787
+ id: contentId,
46788
+ className: "navi_expandable_content_container",
46789
+ inert: opened ? undefined : true,
46790
+ ...rest,
46791
+ children: jsx("div", {
46792
+ className: "navi_expandable_content",
46793
+ children: contentMounted ? content : null
46794
+ })
46795
+ });
46796
+ };
46797
+ Expandable.UI = ExpandableUI;
46798
+ Expandable.Content = ExpandableContent;
46799
+
46800
+ // What a click inside the UI part must not toggle: it was aimed at the
46801
+ // control, not at the row. The marker button is the one exception, excluded at
46802
+ // the call site.
46803
+ const UI_INTERACTIVE_SELECTOR = ["a[href]", "button", "input", "select", "textarea", "label", "[role='button']", "[contenteditable='']", "[contenteditable='true']", "audio[controls]", "video[controls]"].join(", ");
46804
+ const createToggleEvent = open => {
46805
+ const newState = open ? "open" : "closed";
46806
+ const oldState = open ? "closed" : "open";
46807
+ if (typeof window.ToggleEvent === "function") {
46808
+ return new window.ToggleEvent("toggle", {
46809
+ newState,
46810
+ oldState
46811
+ });
46812
+ }
46813
+ const toggleEvent = new CustomEvent("toggle");
46814
+ toggleEvent.newState = newState;
46815
+ toggleEvent.oldState = oldState;
46816
+ return toggleEvent;
46817
+ };
46818
+
46084
46819
  const DEFAULT_VALIDITY_STATE = { valid: true };
46085
46820
  const useConstraintValidityState = (ref) => {
46086
46821
  const checkValue = () => {
@@ -68295,6 +69030,10 @@ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
68295
69030
  * @param {string} [value] The time shown, as "HH:MM".
68296
69031
  * @param {number} [minuteStep=1] How many minutes apart the values on the
68297
69032
  * minute wheel are — 15 for quarters of an hour.
69033
+ * @param {{min?: number, max?: number}|number[]} [hours] Which hours the wheel
69034
+ * offers: `{ min: 7, max: 21 }` for a day that starts and ends somewhere, or
69035
+ * the list itself. All 24 by default. Rows nobody will ever land on are rows
69036
+ * in the way.
68298
69037
  * @param {boolean} [loop=true] The wheels go round: 23h then 0h, 59 minutes
68299
69038
  * then 0. What a clock does. Say `loop={false}` for two ends one cannot turn
68300
69039
  * past.
@@ -68311,6 +69050,7 @@ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
68311
69050
  */
68312
69051
  const TimeWheel = ({
68313
69052
  minuteStep = 1,
69053
+ hours,
68314
69054
  loop = true,
68315
69055
  placeholder,
68316
69056
  separator = naviI18n("time.hour_separator"),
@@ -68329,13 +69069,15 @@ const TimeWheel = ({
68329
69069
  }
68330
69070
  return minuteList;
68331
69071
  }, [minuteStep]);
69072
+ const hourList = useMemo(() => resolveHourList(hours), [hours ? hours.min : undefined, hours ? hours.max : undefined, hours]);
68332
69073
  const {
68333
- aggregateChildStates
68334
- } = useAnswered(placeholder, rest, aggregateTime);
69074
+ aggregateChildStates,
69075
+ distributeChildUIState
69076
+ } = useAnswered(placeholder, rest, aggregateTime, distributeTime);
68335
69077
  const placeholderParts = parseTimeParts(placeholder);
68336
69078
  return jsxs(WheelGroup, {
68337
69079
  aggregateChildStates: aggregateChildStates,
68338
- distributeChildUIState: distributeTime,
69080
+ distributeChildUIState: distributeChildUIState,
68339
69081
  ...rest,
68340
69082
  children: [jsx(Wheel, {
68341
69083
  name: "hour",
@@ -68345,7 +69087,7 @@ const TimeWheel = ({
68345
69087
  "aria-label": hourLabel,
68346
69088
  defaultValue: placeholderParts ? placeholderParts.hour : undefined,
68347
69089
  ...wheelProps,
68348
- children: HOURS.map(hour => jsx(Wheel.Item, {
69090
+ children: hourList.map(hour => jsx(Wheel.Item, {
68349
69091
  value: hour,
68350
69092
  paddingX: "s",
68351
69093
  children: padTwo(hour)
@@ -68392,6 +69134,8 @@ const TimeWheel = ({
68392
69134
  * `null` for neither.
68393
69135
  * @param {number} [minuteStep=1] How many minutes apart the values on both
68394
69136
  * minute wheels are.
69137
+ * @param {{min?: number, max?: number}|number[]} [hours] Which hours both
69138
+ * wheels offer — see `TimeWheel`.
68395
69139
  * @param {number} [minDuration=0] How long the span must last at least, in
68396
69140
  * minutes. Zero by default: a span of no length is a span all the same, only
68397
69141
  * one that goes backwards is not. It is what the bounds keep between them as
@@ -68409,6 +69153,7 @@ const TimeWheel = ({
68409
69153
  */
68410
69154
  const TimeRangeWheel = ({
68411
69155
  minuteStep = 1,
69156
+ hours,
68412
69157
  minDuration = 0,
68413
69158
  loop = true,
68414
69159
  placeholder,
@@ -68427,8 +69172,9 @@ const TimeRangeWheel = ({
68427
69172
  // answer too, left where the placeholder put it.
68428
69173
  const {
68429
69174
  answeredRef,
68430
- aggregateChildStates
68431
- } = useAnswered(placeholder, rest, aggregateSpan);
69175
+ aggregateChildStates,
69176
+ distributeChildUIState
69177
+ } = useAnswered(placeholder, rest, aggregateSpan, distributeSpan);
68432
69178
 
68433
69179
  // What the pair does while it is being turned: the bound that just moved is
68434
69180
  // the one the user is holding, so it stays where it was put and the OTHER one
@@ -68471,6 +69217,7 @@ const TimeRangeWheel = ({
68471
69217
  spacing: "s",
68472
69218
  size: size,
68473
69219
  aggregateChildStates: aggregateChildStates,
69220
+ distributeChildUIState: distributeChildUIState,
68474
69221
  ...rest,
68475
69222
  children: jsxs(AnsweredContext.Provider, {
68476
69223
  value: answeredRef,
@@ -68482,6 +69229,7 @@ const TimeRangeWheel = ({
68482
69229
  ref: startRef,
68483
69230
  name: "start",
68484
69231
  minuteStep: minuteStep,
69232
+ hours: hours,
68485
69233
  loop: loop,
68486
69234
  size: size,
68487
69235
  placeholder: placeholder ? placeholder.start : undefined,
@@ -68495,6 +69243,7 @@ const TimeRangeWheel = ({
68495
69243
  ref: endRef,
68496
69244
  name: "end",
68497
69245
  minuteStep: minuteStep,
69246
+ hours: hours,
68498
69247
  loop: loop,
68499
69248
  size: size,
68500
69249
  placeholder: placeholder ? placeholder.end : undefined,
@@ -68536,7 +69285,7 @@ const TimeRangeWheel = ({
68536
69285
  * compare its own placeholder against.
68537
69286
  */
68538
69287
  const AnsweredContext = createContext(null);
68539
- const useAnswered = (placeholder, props, aggregateWhenAnswered) => {
69288
+ const useAnswered = (placeholder, props, aggregateWhenAnswered, distributeWhenAnswered) => {
68540
69289
  const answeredFromPair = useContext(AnsweredContext);
68541
69290
  const ownAnsweredRef = useRef(false);
68542
69291
  const answeredRef = answeredFromPair || ownAnsweredRef;
@@ -68548,31 +69297,81 @@ const useAnswered = (placeholder, props, aggregateWhenAnswered) => {
68548
69297
  const gates = !answeredFromPair;
68549
69298
  const placeholderRef = useRef(placeholder);
68550
69299
  placeholderRef.current = placeholder;
68551
- const aggregateRef = useRef(null);
68552
- if (!aggregateRef.current) {
68553
- aggregateRef.current = children => {
68554
- const aggregated = aggregateWhenAnswered(children);
68555
- if (answeredRef.current) {
69300
+ const scopeRef = useRef(null);
69301
+ if (!scopeRef.current) {
69302
+ scopeRef.current = {
69303
+ aggregateChildStates: children => {
69304
+ const aggregated = aggregateWhenAnswered(children);
69305
+ if (answeredRef.current) {
69306
+ return aggregated;
69307
+ }
69308
+ if (compareTwoJsValues(aggregated, placeholderRef.current)) {
69309
+ return undefined;
69310
+ }
69311
+ // It moved: from here on this is an answer, and stays one even when it
69312
+ // is turned back onto the placeholder — somebody chose that time.
69313
+ answeredRef.current = true;
68556
69314
  return aggregated;
69315
+ },
69316
+ distributeChildUIState: (groupState, child) => {
69317
+ if (placeholderRef.current && holdsNothing(groupState)) {
69318
+ // Being told there is no value is not a finger bringing a wheel back:
69319
+ // it is a clear, or the app writing `undefined`. The wheels go back to
69320
+ // showing the placeholder, and the pair is unanswered again — without
69321
+ // this the wheels would empty (a wheel has no blank row) and what they
69322
+ // still showed would climb straight back up as an answer.
69323
+ answeredRef.current = false;
69324
+ return distributeWhenAnswered(placeholderRef.current, child);
69325
+ }
69326
+ return distributeWhenAnswered(groupState, child);
68557
69327
  }
68558
- if (compareTwoJsValues(aggregated, placeholderRef.current)) {
68559
- return undefined;
68560
- }
68561
- // It moved: from here on this is an answer, and stays one even when it is
68562
- // turned back onto the placeholder — somebody chose that time.
68563
- answeredRef.current = true;
68564
- return aggregated;
68565
69328
  };
68566
69329
  }
68567
69330
  return {
68568
69331
  answeredRef,
68569
- aggregateChildStates: gates ? aggregateRef.current : aggregateWhenAnswered
69332
+ aggregateChildStates: gates ? scopeRef.current.aggregateChildStates : aggregateWhenAnswered,
69333
+ distributeChildUIState: gates ? scopeRef.current.distributeChildUIState : distributeWhenAnswered
68570
69334
  };
68571
69335
  };
69336
+
69337
+ // Nothing at all: no value, an empty shape, or a shape whose every part is
69338
+ // itself nothing — which is what a cleared span looks like on the way down.
69339
+ const holdsNothing = value => {
69340
+ if (value === undefined || value === null || value === "") {
69341
+ return true;
69342
+ }
69343
+ if (typeof value !== "object") {
69344
+ return false;
69345
+ }
69346
+ return Object.values(value).every(holdsNothing);
69347
+ };
68572
69348
  const isAnswerGivenByProps = props => props.value !== undefined || props.defaultValue !== undefined || props.signal && props.signal.value !== undefined;
68573
- const HOURS = Array.from({
69349
+
69350
+ // Which hours the wheel offers: all of them, a slice of the day, or a list
69351
+ // written by the caller. An app whose day ends at 21h has nothing to say about
69352
+ // 22h and 23h, and two rows nobody will ever land on are two rows in the way.
69353
+ const ALL_HOURS = Array.from({
68574
69354
  length: HOUR_COUNT
68575
69355
  }, (_, hour) => hour);
69356
+ const resolveHourList = hours => {
69357
+ if (hours === undefined) {
69358
+ return ALL_HOURS;
69359
+ }
69360
+ if (Array.isArray(hours)) {
69361
+ return hours;
69362
+ }
69363
+ const {
69364
+ min = 0,
69365
+ max = HOUR_COUNT - 1
69366
+ } = hours;
69367
+ const hourList = [];
69368
+ let hour = min;
69369
+ while (hour <= max) {
69370
+ hourList.push(hour);
69371
+ hour += 1;
69372
+ }
69373
+ return hourList;
69374
+ };
68576
69375
  const padTwo = value => String(value).padStart(2, "0");
68577
69376
 
68578
69377
  // The two times as one span, { start, end } — the shape a pair carries.
@@ -68586,6 +69385,14 @@ const aggregateSpan = childUIStateControllers => {
68586
69385
  return span;
68587
69386
  };
68588
69387
 
69388
+ // The way back for a span: each time takes its own side.
69389
+ const distributeSpan = (groupState, childUIStateController) => {
69390
+ if (!groupState) {
69391
+ return undefined;
69392
+ }
69393
+ return groupState[childUIStateController.name];
69394
+ };
69395
+
68589
69396
  // The two wheels as one value, "HH:MM".
68590
69397
  const aggregateTime = childUIStateControllers => {
68591
69398
  let hour = "";
@@ -74645,5 +75452,5 @@ const UserSvg = () => jsx("svg", {
74645
75452
  })
74646
75453
  });
74647
75454
 
74648
- 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, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCanNavBack, useCanNavForward, 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, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
75455
+ 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, Expandable, 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, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCanNavBack, useCanNavForward, 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, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
74649
75456
  //# sourceMappingURL=jsenv_navi.js.map