@jsenv/navi 0.29.88 → 0.29.90

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.
@@ -24381,6 +24381,13 @@ const GROUP_DEFAULTS = {
24381
24381
  ? emptyUIState
24382
24382
  : child.uiState;
24383
24383
  if (!name) {
24384
+ if (allowNameless) {
24385
+ // A control that says it is not a field is not one, whatever it
24386
+ // holds: a picker used as a door holds the shape its popup draws,
24387
+ // and merging that in would put the popup's keys in the object as
24388
+ // if the door had been a group.
24389
+ continue;
24390
+ }
24384
24391
  // A nameless GROUP is a grouping, not a value: it exists to hold its
24385
24392
  // children together (a WheelGroup sharing navigation, a fieldset-ish
24386
24393
  // cluster) without claiming a key of its own, so what it holds is
@@ -24390,12 +24397,10 @@ const GROUP_DEFAULTS = {
24390
24397
  Object.assign(groupValues, uiState);
24391
24398
  continue;
24392
24399
  }
24393
- if (!allowNameless) {
24394
- console.warn(
24395
- "A group child is missing a name property, its state won't be included in the group state",
24396
- child,
24397
- );
24398
- }
24400
+ console.warn(
24401
+ "A group child is missing a name property, its state won't be included in the group state",
24402
+ child,
24403
+ );
24399
24404
  continue;
24400
24405
  }
24401
24406
  groupValues[name] = uiState;
@@ -24520,6 +24525,14 @@ const useUIGroupStateController = (
24520
24525
  : stateType === "object"
24521
24526
  ? EMPTY_OBJECT
24522
24527
  : undefined;
24528
+ // A group told what it holds holds it from the start, before any child has
24529
+ // registered to show it: what it was given is the answer, and the children
24530
+ // are where that answer is shown (see stateGivenFromAbove).
24531
+ const stateInitial = hasValueProp
24532
+ ? value
24533
+ : hasDefaultValueProp && defaultValue !== undefined
24534
+ ? defaultValue
24535
+ : fallbackState;
24523
24536
  const childUIStateControllerArrayRef = useRef([]);
24524
24537
  const childUIStateControllerArray = childUIStateControllerArrayRef.current;
24525
24538
  // Tracks children rejected by the filter and delegated upward (bubble-up).
@@ -24583,14 +24596,35 @@ const useUIGroupStateController = (
24583
24596
  `Creating "${controlType}" ui state controller (monitoring some descendants ui state(s))"`,
24584
24597
  );
24585
24598
  const [publishUIState, subscribeUIState] = createPubSub();
24586
- const uiStateSignal = signal(fallbackState);
24587
-
24588
- const aggregateGroupUIState = () => {
24599
+ const uiStateSignal = signal(stateInitial);
24600
+
24601
+ // What the group is worth right now, and what it keeps when there is
24602
+ // nobody to ask: a list whose items have not arrived yet, a popup built
24603
+ // at open, a group whose children are still mounting. Such a group has
24604
+ // no opinion — its aggregate falls back to the empty of its type, and
24605
+ // taking that for an answer is how a value handed to it evaporates on
24606
+ // the way in, and how that emptiness then travels back up to whoever
24607
+ // handed it (a picker showing its row as unanswered).
24608
+ const aggregateGroupUIState = (whenNobodyCanAnswer) => {
24609
+ const someChildCanAnswer = childUIStateControllerArray.some(
24610
+ shouldPropagateStateToChild,
24611
+ );
24612
+ if (!someChildCanAnswer) {
24613
+ return whenNobodyCanAnswer;
24614
+ }
24589
24615
  const aggChildState = resolvedAggregateChildStates(
24590
24616
  childUIStateControllerArray,
24591
24617
  fallbackState,
24592
24618
  );
24593
- return aggChildState === undefined ? fallbackState : aggChildState;
24619
+ if (aggChildState !== undefined) {
24620
+ return aggChildState;
24621
+ }
24622
+ // A group with an aggregate of its own is the one who knows what its
24623
+ // children add up to, `undefined` included — half a time is not a time,
24624
+ // wheels nobody turned have settled nothing. Only the default shapes
24625
+ // fall back to the empty of their type, where "no child says anything"
24626
+ // and "the value is empty" are the same sentence.
24627
+ return stateShapeIsTheDefaultOne ? fallbackState : undefined;
24594
24628
  };
24595
24629
 
24596
24630
  // onChange and applyState live inside init so they close over the stable
@@ -24611,13 +24645,26 @@ const useUIGroupStateController = (
24611
24645
  };
24612
24646
  return;
24613
24647
  }
24614
- const groupUIState = aggregateGroupUIState();
24648
+ const { controller } = s;
24649
+ // A child mounting or unmounting is not somebody answering: while the
24650
+ // children of a group are still arriving, their aggregate is a partial
24651
+ // reading, and taking it for the truth is how the value the group was
24652
+ // given gets destroyed one row at a time — the first row to register
24653
+ // aggregates alone, the group drops to that, and every row after it is
24654
+ // placed from what is left. A group that derived its own value has
24655
+ // nothing to protect and aggregates as usual.
24656
+ const groupUIState =
24657
+ notifyExternal === "silent" && controller.stateGivenFromAbove
24658
+ ? controller.uiState
24659
+ : aggregateGroupUIState(controller.uiState);
24615
24660
  debugUIGroup(
24616
24661
  e,
24617
24662
  `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
24618
24663
  );
24619
- const { controller } = s;
24620
24664
  if (notifyExternal === true) {
24665
+ // Somebody answered: what the group is worth is what its children say
24666
+ // between them, from here on.
24667
+ controller.stateGivenFromAbove = false;
24621
24668
  applyState(groupUIState, e);
24622
24669
  } else if (notifyExternal === "silent") {
24623
24670
  controller.syncInternalState(groupUIState);
@@ -24683,7 +24730,11 @@ const useUIGroupStateController = (
24683
24730
  hasValueProp,
24684
24731
  hasDefaultValueProp,
24685
24732
  props,
24686
- uiState: fallbackState,
24733
+ uiState: stateInitial,
24734
+ // Whether what the group holds was HANDED to it (a parent distributing,
24735
+ // a picker filling its popup, a value prop) rather than worked out from
24736
+ // its children. What it protects is read in onChange.
24737
+ stateGivenFromAbove: hasValueProp || hasDefaultValueProp,
24687
24738
  uiStateSignal,
24688
24739
  wantRequesterButtonState,
24689
24740
  ref,
@@ -24696,9 +24747,6 @@ const useUIGroupStateController = (
24696
24747
  if (!shouldPropagateStateToChild(childUIStateController)) {
24697
24748
  return;
24698
24749
  }
24699
- if (childUIStateController.hasStateProp) {
24700
- return;
24701
- }
24702
24750
  const childNewState = resolvedDistributeChildUIState(
24703
24751
  groupUIState,
24704
24752
  childUIStateController,
@@ -24706,6 +24754,22 @@ const useUIGroupStateController = (
24706
24754
  if (childNewState === CANNOT_DERIVE) {
24707
24755
  return;
24708
24756
  }
24757
+ if (
24758
+ childUIStateController.hasStateProp &&
24759
+ !childUIStateController.props.signal
24760
+ ) {
24761
+ // A child bound to a signal is placed like any other: bound is not
24762
+ // frozen, and the placement writes the signal, so both ends keep
24763
+ // saying the same thing. Only a child controlled by a `value` /
24764
+ // `checked` prop cannot be moved — its owner decides. Worth saying
24765
+ // out loud only when the two disagree: a child already showing what
24766
+ // the group would put there has lost nothing, and both being fed
24767
+ // from the same value is a legitimate way to write a group.
24768
+ if (
24769
+ !compareTwoJsValues(childNewState, childUIStateController.uiState)
24770
+ ) ;
24771
+ return;
24772
+ }
24709
24773
  childUIStateController.setUIState(childNewState, e);
24710
24774
  },
24711
24775
  setUIState: (newUIState, e) => {
@@ -24731,6 +24795,7 @@ const useUIGroupStateController = (
24731
24795
  );
24732
24796
  return;
24733
24797
  }
24798
+ controller.stateGivenFromAbove = true;
24734
24799
  const propagateEventType =
24735
24800
  e.type === "initial_state_push"
24736
24801
  ? "initial_state_push"
@@ -24746,9 +24811,10 @@ const useUIGroupStateController = (
24746
24811
  propagateDownEvent,
24747
24812
  );
24748
24813
  }
24749
- const groupUIState = aggregateGroupUIState();
24814
+ const groupUIState = aggregateGroupUIState(newUIState);
24750
24815
  if (e.type === "initial_state_push") {
24751
24816
  controller.syncInternalState(groupUIState);
24817
+ writeBoundSignal(groupUIState);
24752
24818
  return;
24753
24819
  }
24754
24820
  applyState(groupUIState, e, { internalBehavior: true });
@@ -24800,15 +24866,28 @@ const useUIGroupStateController = (
24800
24866
  debugUIGroup(
24801
24867
  `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
24802
24868
  );
24803
- if (controller.hasValueProp || controller.hasDefaultValueProp) {
24869
+ const stateToPlaceChildFrom = controller.hasValueProp
24870
+ ? controller.value
24871
+ : controller.hasDefaultValueProp
24872
+ ? controller.defaultValue
24873
+ : // What the group HOLDS, for a child arriving after the value
24874
+ // did: a list item loaded later, a row scrolled back into a
24875
+ // virtualized list, a popup built at open. Two conditions, and
24876
+ // both are about not overwriting an answer with a silence — the
24877
+ // group must actually hold something, and the child must have
24878
+ // nothing of its own to show (one arriving with its own default
24879
+ // is answering, and the group is what its answers add up to).
24880
+ uiStateHoldsNothing(controller.uiState) ||
24881
+ !uiStateHoldsNothing(childUIStateController.uiState)
24882
+ ? undefined
24883
+ : controller.uiState;
24884
+ if (stateToPlaceChildFrom !== undefined) {
24804
24885
  const initialEvent = new CustomEvent("initial_state_push", {
24805
24886
  detail: {},
24806
24887
  });
24807
24888
  controller.placeChildUIState(
24808
24889
  childUIStateController,
24809
- controller.hasValueProp
24810
- ? controller.value
24811
- : controller.defaultValue,
24890
+ stateToPlaceChildFrom,
24812
24891
  initialEvent,
24813
24892
  );
24814
24893
  }
@@ -25097,6 +25176,7 @@ const EMPTY_OBJECT = {};
25097
25176
  */
25098
25177
  const useUIFacadeStateController = (props, realUIStateController) => {
25099
25178
  const firstChildControllerRef = useRef(null);
25179
+ const namelessChildSetRef = useRef(new Set());
25100
25180
  const updatingRef = useRef(false);
25101
25181
  const debugPopup = useDebugPopup();
25102
25182
  const debugInteraction = useDebugInteraction();
@@ -25128,6 +25208,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25128
25208
  // A control saying it is not a field is not the one the picker talks
25129
25209
  // to: the search box above the list, the "select all" switch beside
25130
25210
  // it. It is there to help find the answer, not to be it.
25211
+ namelessChildSetRef.current.add(childController);
25131
25212
  return false;
25132
25213
  }
25133
25214
  if (childController.props["navi-list"]) {
@@ -25150,6 +25231,10 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25150
25231
  }
25151
25232
  const child = firstChildControllerRef.current;
25152
25233
  if (!child) {
25234
+ warnPopupHasNothingButNamelessControls(
25235
+ props,
25236
+ namelessChildSetRef.current,
25237
+ );
25153
25238
  return;
25154
25239
  }
25155
25240
  updatingRef.current = true;
@@ -25298,6 +25383,11 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25298
25383
 
25299
25384
  const describePicker = (props) =>
25300
25385
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
25386
+ const warnPopupHasNothingButNamelessControls = (props, namelessChildSet) => {
25387
+ {
25388
+ return;
25389
+ }
25390
+ };
25301
25391
 
25302
25392
  /**
25303
25393
  * Returns true when `e` should trigger parent notification (child → parent bubbling).
@@ -25370,6 +25460,11 @@ const PROPAGATE_DOWN_EVENT_SET = new Set([
25370
25460
  "propagate_down_set_ui_state",
25371
25461
  "propagate_down_reset_ui_state",
25372
25462
  "propagate_down_clear_ui_state",
25463
+ // The FIRST value handed down is one too: a control placed as it registers
25464
+ // (a group filling a child that just arrived, a picker filling its popup)
25465
+ // holds it from that moment, and a signal that kept saying nothing would have
25466
+ // the app and the screen disagree from the very first paint.
25467
+ "initial_state_push",
25373
25468
  ]);
25374
25469
  const isPropagateDownEvent = (e) => {
25375
25470
  return PROPAGATE_DOWN_EVENT_SET.has(e.type);
@@ -28015,6 +28110,7 @@ const createOpenController = (
28015
28110
  // Last: the close effects above are what starts the exit transition the
28016
28111
  // content must outlive (see popup_content_mount.js).
28017
28112
  controller.unmountContent?.();
28113
+ controller.onOpenedChange?.(false);
28018
28114
  };
28019
28115
  const controller = {
28020
28116
  opened: false,
@@ -28033,6 +28129,11 @@ const createOpenController = (
28033
28129
  // The counterpart, set only when the popup was told to throw its content
28034
28130
  // away on close (`unmountWhenClosed`). Called from performClose above.
28035
28131
  unmountContent: null,
28132
+ // Told whenever `opened` actually changes, whatever asked for it — an
28133
+ // interaction, a command, a prop. What lets a `signal` prop reflect the
28134
+ // popup's real state (see useOpenPropsEffectOnOpenController), called once
28135
+ // the open/close has fully happened rather than mid-sequence.
28136
+ onOpenedChange: null,
28036
28137
  open: (e, detail) => {
28037
28138
  if (controller.opened || !controller.openEffect) {
28038
28139
  return;
@@ -28135,6 +28236,7 @@ const createOpenController = (
28135
28236
  openEffectReturnValue?.(closeEvent);
28136
28237
  };
28137
28238
  closeHandlers = openHandler(requestOpenEvent) || null;
28239
+ controller.onOpenedChange?.(true);
28138
28240
  },
28139
28241
  requestClose: (
28140
28242
  e = new CustomEvent("programmatic", { detail: {} }),
@@ -28263,10 +28365,23 @@ const scheduleMountOpen = (run) => {
28263
28365
  * `requestOpen`/`requestClose` wrappers).
28264
28366
  *
28265
28367
  * @param {{ open: (e: Event, detail?: object) => void, requestClose: (e: Event, detail?: object) => void, opened: boolean }} openController
28266
- * @param {{ open?: boolean|"interaction", defaultOpen?: boolean|"interaction" }} props
28368
+ * @param {{ open?: boolean|"interaction", defaultOpen?: boolean|"interaction", signal?: import("@preact/signals").Signal<boolean> }} props
28267
28369
  */
28268
28370
  const useOpenPropsEffectOnOpenController = (openController, props) => {
28269
- const { open, defaultOpen } = props;
28371
+ const { signal, defaultOpen } = props;
28372
+ // What the caller holds, however they hold it: an `open` they re-render
28373
+ // themselves, or a `signal` this hook also writes (see onOpenedChange below).
28374
+ // Reading .value during render is what subscribes the popup to it.
28375
+ const open = signal ? signal.value : props.open;
28376
+ // Assigned on every render, like openEffect, so it always closes over the
28377
+ // latest prop: a popup that opens or closes on its own (Escape, backdrop, a
28378
+ // --navi-close command) writes what happened into the signal, so whoever
28379
+ // holds it always reads where the popup is.
28380
+ openController.onOpenedChange = signal
28381
+ ? (opened) => {
28382
+ signal.value = opened;
28383
+ }
28384
+ : null;
28270
28385
  // Tracks whether the effect below has ever run before — only the very
28271
28386
  // first run gets the "mount already open" treatment (`open` truthy from
28272
28387
  // the start, or the uncontrolled, mount-only `defaultOpen`); every
@@ -28319,6 +28434,12 @@ const useOpenPropsEffectOnOpenController = (openController, props) => {
28319
28434
  { isCancel: true },
28320
28435
  );
28321
28436
  }
28437
+ if (signal) {
28438
+ // The request can be refused (a busy form denying the close): the popup
28439
+ // then stays where it was, and the signal is told so — otherwise it
28440
+ // would keep saying "closed" about a popup still open.
28441
+ signal.value = openController.opened;
28442
+ }
28322
28443
  }, [open]);
28323
28444
  };
28324
28445
 
@@ -29743,6 +29864,14 @@ const css$X = /* css */`
29743
29864
  * the focus only leaves it for something that asked by name (`autoFocus` on
29744
29865
  * that element, which outranks whatever the dialog says).
29745
29866
  * @param {boolean} [props.open] - Controlled open state.
29867
+ * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
29868
+ * state said the way every navi control says it: the dialog opens and closes
29869
+ * to match the signal, and writes into it whenever it opens or closes on its
29870
+ * own (Escape, backdrop, a --navi-close command) — one binding to both drive
29871
+ * the dialog and know where it is, and the state stays where the app put it.
29872
+ * Excludes `open`; `onOpen`/`onClose` still fire. A signal holding `true` at
29873
+ * mount behaves like `defaultOpen`: the dialog was already open, no entrance
29874
+ * plays.
29746
29875
  * @param {boolean|"interaction"} [props.defaultOpen] - Uncontrolled, mount-only
29747
29876
  * initial open state. `true` plays no entrance animation: the dialog was
29748
29877
  * already open when the page appeared, and nothing was ever shown as "closed"
@@ -29825,6 +29954,7 @@ const UncontrolledDialog = props => {
29825
29954
  return jsx(ControlledDialog, {
29826
29955
  ...props,
29827
29956
  open: undefined,
29957
+ signal: undefined,
29828
29958
  defaultOpen: undefined,
29829
29959
  onClose: undefined,
29830
29960
  openController: openController,
@@ -31182,6 +31312,14 @@ const css$W = /* css */`
31182
31312
  * the focus only leaves it for something that asked by name (`autoFocus` on
31183
31313
  * that element, which outranks whatever the popover says).
31184
31314
  * @param {boolean} [props.open] - Controlled open state.
31315
+ * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
31316
+ * state said the way every navi control says it: the popover opens and closes
31317
+ * to match the signal, and writes into it whenever it opens or closes on its
31318
+ * own (Escape, light dismiss, a --navi-close command) — one binding to both
31319
+ * drive the popover and know where it is, and the state stays where the app
31320
+ * put it. Excludes `open`; `onOpen`/`onClose` still fire. A signal holding
31321
+ * `true` at mount behaves like `defaultOpen`: the popover was already open,
31322
+ * no entrance plays.
31185
31323
  * @param {boolean|"interaction"} [props.defaultOpen] - Uncontrolled, mount-only
31186
31324
  * initial open state. `true` plays no entrance animation: the popover was
31187
31325
  * already open when the page appeared, and nothing was ever shown as "closed"
@@ -31262,6 +31400,7 @@ const UncontrolledPopover = props => {
31262
31400
  return jsx(ControlledPopover, {
31263
31401
  ...props,
31264
31402
  open: undefined,
31403
+ signal: undefined,
31265
31404
  defaultOpen: undefined,
31266
31405
  onClose: undefined,
31267
31406
  openController: openController,
@@ -54086,6 +54225,9 @@ const css$A = /* css */`
54086
54225
  * to `--dialog-maxmax-height` (`data-expand-y`).
54087
54226
  * @param {boolean} [props.scrollCapture] - Forwarded as-is.
54088
54227
  * @param {boolean} [props.open] - Forwarded as-is (controlled).
54228
+ * @param {import("@preact/signals").Signal<boolean>} [props.signal] -
54229
+ * Forwarded as-is: one binding to both drive the popup's open state and
54230
+ * know where it is (see `Dialog`/`Popover`'s own `signal`).
54089
54231
  * @param {boolean} [props.defaultOpen] - Forwarded as-is (uncontrolled,
54090
54232
  * mount-only).
54091
54233
  * @param {(event: Event) => void} [props.onClose] - Forwarded as-is.
@@ -68158,12 +68300,19 @@ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
68158
68300
  * past.
68159
68301
  * @param {import("ignore:preact").ComponentChildren} [separator] What is written
68160
68302
  * between the hours and the minutes. "h" in French, ":" elsewhere.
68303
+ * @param {string} [placeholder] What the wheels show while the time holds
68304
+ * nothing, as "HH:MM". Wheels have no blank row to land on, so their
68305
+ * placeholder is a position rather than a grey word — shown, but not an
68306
+ * answer: the value stays `undefined` until a wheel is turned or a real value
68307
+ * arrives. `defaultValue` is the other half of the pair — a time that IS the
68308
+ * answer, and where a reset goes back to.
68161
68309
  * @param {object} [wheelProps] Anything a `Wheel` takes, said once for both of
68162
68310
  * them — `visibleCount`, `itemWidth`, `glideSpeed`.
68163
68311
  */
68164
68312
  const TimeWheel = ({
68165
68313
  minuteStep = 1,
68166
68314
  loop = true,
68315
+ placeholder,
68167
68316
  separator = naviI18n("time.hour_separator"),
68168
68317
  hourLabel = naviI18n("time.hour_label"),
68169
68318
  minuteLabel = naviI18n("time.minute_label"),
@@ -68180,8 +68329,12 @@ const TimeWheel = ({
68180
68329
  }
68181
68330
  return minuteList;
68182
68331
  }, [minuteStep]);
68332
+ const {
68333
+ aggregateChildStates
68334
+ } = useAnswered(placeholder, rest, aggregateTime);
68335
+ const placeholderParts = parseTimeParts(placeholder);
68183
68336
  return jsxs(WheelGroup, {
68184
- aggregateChildStates: aggregateTime,
68337
+ aggregateChildStates: aggregateChildStates,
68185
68338
  distributeChildUIState: distributeTime,
68186
68339
  ...rest,
68187
68340
  children: [jsx(Wheel, {
@@ -68190,6 +68343,7 @@ const TimeWheel = ({
68190
68343
  bounded: !loop,
68191
68344
  size: size,
68192
68345
  "aria-label": hourLabel,
68346
+ defaultValue: placeholderParts ? placeholderParts.hour : undefined,
68193
68347
  ...wheelProps,
68194
68348
  children: HOURS.map(hour => jsx(Wheel.Item, {
68195
68349
  value: hour,
@@ -68205,6 +68359,7 @@ const TimeWheel = ({
68205
68359
  bounded: !loop,
68206
68360
  size: size,
68207
68361
  "aria-label": minuteLabel,
68362
+ defaultValue: placeholderParts ? placeholderParts.minute : undefined,
68208
68363
  ...wheelProps,
68209
68364
  children: minutes.map(minute => jsx(Wheel.Item, {
68210
68365
  value: minute,
@@ -68242,6 +68397,12 @@ const TimeWheel = ({
68242
68397
  * one that goes backwards is not. It is what the bounds keep between them as
68243
68398
  * they turn — turn the start into the end and the end moves along, keeping
68244
68399
  * that much room.
68400
+ * @param {{ start?: string, end?: string }} [placeholder] What the two wheels
68401
+ * show while the span holds nothing — a position, since wheels have no blank
68402
+ * row to land on, and not an answer: the value stays `undefined` until one of
68403
+ * them is turned. One turn settles both, the untouched bound included, left
68404
+ * where the placeholder put it. For a span that is optional ("any time of
68405
+ * day") and still has to show hours.
68245
68406
  * @param {object} [timeProps] Anything a `TimeWheel` takes, said once for both
68246
68407
  * of them. `startTimeProps`/`endTimeProps` say it to one of the two, and win
68247
68408
  * over this one.
@@ -68250,6 +68411,7 @@ const TimeRangeWheel = ({
68250
68411
  minuteStep = 1,
68251
68412
  minDuration = 0,
68252
68413
  loop = true,
68414
+ placeholder,
68253
68415
  size,
68254
68416
  startLabel = naviI18n("time_range.from"),
68255
68417
  endLabel = naviI18n("time_range.to"),
@@ -68261,6 +68423,12 @@ const TimeRangeWheel = ({
68261
68423
  const startId = useId();
68262
68424
  const startRef = useRef(null);
68263
68425
  const endRef = useRef(null);
68426
+ // One turn settles the whole span: a start somebody chose makes the end an
68427
+ // answer too, left where the placeholder put it.
68428
+ const {
68429
+ answeredRef,
68430
+ aggregateChildStates
68431
+ } = useAnswered(placeholder, rest, aggregateSpan);
68264
68432
 
68265
68433
  // What the pair does while it is being turned: the bound that just moved is
68266
68434
  // the one the user is holding, so it stays where it was put and the OTHER one
@@ -68297,51 +68465,127 @@ const TimeRangeWheel = ({
68297
68465
  event: e
68298
68466
  });
68299
68467
  };
68300
- return jsxs(ControlGroup, {
68468
+ return jsx(ControlGroup, {
68301
68469
  flex: true,
68302
68470
  alignY: "center",
68303
68471
  spacing: "s",
68304
68472
  size: size,
68473
+ aggregateChildStates: aggregateChildStates,
68305
68474
  ...rest,
68306
- children: [startLabel === null ? null : jsx(Text, {
68307
- size: size,
68308
- children: startLabel
68309
- }), jsx(TimeWheel, {
68310
- id: startId,
68311
- ref: startRef,
68312
- name: "start",
68313
- minuteStep: minuteStep,
68314
- loop: loop,
68315
- size: size,
68316
- uiAction: (value, e) => keepBoundsApart("start", value, e),
68317
- ...timeProps,
68318
- ...startTimeProps
68319
- }), endLabel === null ? null : jsx(Text, {
68320
- size: size,
68321
- children: endLabel
68322
- }), jsx(TimeWheel, {
68323
- ref: endRef,
68324
- name: "end",
68325
- minuteStep: minuteStep,
68326
- loop: loop,
68327
- size: size,
68328
- uiAction: (value, e) => keepBoundsApart("end", value, e)
68329
- // Which time it comes after, and how much room there must be between
68330
- // the two: said on the LATER of the two, so the answer is given where
68331
- // the time one would have to move is (see time_range_constraint.js).
68332
- ,
68333
- "data-time-after": startId,
68334
- "data-time-min-duration": minDuration,
68335
- ...timeProps,
68336
- ...endTimeProps
68337
- })]
68475
+ children: jsxs(AnsweredContext.Provider, {
68476
+ value: answeredRef,
68477
+ children: [startLabel === null ? null : jsx(Text, {
68478
+ size: size,
68479
+ children: startLabel
68480
+ }), jsx(TimeWheel, {
68481
+ id: startId,
68482
+ ref: startRef,
68483
+ name: "start",
68484
+ minuteStep: minuteStep,
68485
+ loop: loop,
68486
+ size: size,
68487
+ placeholder: placeholder ? placeholder.start : undefined,
68488
+ uiAction: (value, e) => keepBoundsApart("start", value, e),
68489
+ ...timeProps,
68490
+ ...startTimeProps
68491
+ }), endLabel === null ? null : jsx(Text, {
68492
+ size: size,
68493
+ children: endLabel
68494
+ }), jsx(TimeWheel, {
68495
+ ref: endRef,
68496
+ name: "end",
68497
+ minuteStep: minuteStep,
68498
+ loop: loop,
68499
+ size: size,
68500
+ placeholder: placeholder ? placeholder.end : undefined,
68501
+ uiAction: (value, e) => keepBoundsApart("end", value, e)
68502
+ // Which time it comes after, and how much room there must be between
68503
+ // the two: said on the LATER of the two, so the answer is given where
68504
+ // the time one would have to move is (see time_range_constraint.js).
68505
+ ,
68506
+ "data-time-after": startId,
68507
+ "data-time-min-duration": minDuration,
68508
+ ...timeProps,
68509
+ ...endTimeProps
68510
+ })]
68511
+ })
68338
68512
  });
68339
68513
  };
68514
+
68515
+ /**
68516
+ * Wheels always show something — there is no blank row to land on — so a pair of
68517
+ * them cannot say "nothing set" by looking empty. Their `placeholder` is
68518
+ * therefore a position rather than a grey word: shown like a value, and not one.
68519
+ * The value stays `undefined` until a wheel is turned, which is what tells "any
68520
+ * time of day" from a span somebody chose. `defaultValue` remains what it is
68521
+ * everywhere else — a time that IS the answer.
68522
+ *
68523
+ * What counts as turned is read from the value itself rather than from a
68524
+ * gesture: while nothing has moved off the placeholder, nothing is set; the
68525
+ * moment it differs, it is an answer and stays one, even turned back onto the
68526
+ * placeholder. A wheel's own `uiAction` runs after its group has aggregated, so
68527
+ * a flag set from there would always be one turn late.
68528
+ *
68529
+ * The flag is a ref rather than state because the aggregate a group is created
68530
+ * with is the one it keeps: swapping the function on a later render changes
68531
+ * nothing (see useUIGroupStateController). One stable function reading one ref.
68532
+ *
68533
+ * A pair shares ONE flag through `AnsweredContext`: turning the start settles
68534
+ * the end too, left where the placeholder put it. Each of the two times gating
68535
+ * on its own would answer half a span — and would leave the pair nothing to
68536
+ * compare its own placeholder against.
68537
+ */
68538
+ const AnsweredContext = createContext(null);
68539
+ const useAnswered = (placeholder, props, aggregateWhenAnswered) => {
68540
+ const answeredFromPair = useContext(AnsweredContext);
68541
+ const ownAnsweredRef = useRef(false);
68542
+ const answeredRef = answeredFromPair || ownAnsweredRef;
68543
+ if (!placeholder || isAnswerGivenByProps(props)) {
68544
+ answeredRef.current = true;
68545
+ }
68546
+ // Inside a pair, only the pair decides: a time that gated on its own would
68547
+ // hand the pair nothing to compare, and half a span cannot be read.
68548
+ const gates = !answeredFromPair;
68549
+ const placeholderRef = useRef(placeholder);
68550
+ 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) {
68556
+ return aggregated;
68557
+ }
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
+ };
68566
+ }
68567
+ return {
68568
+ answeredRef,
68569
+ aggregateChildStates: gates ? aggregateRef.current : aggregateWhenAnswered
68570
+ };
68571
+ };
68572
+ const isAnswerGivenByProps = props => props.value !== undefined || props.defaultValue !== undefined || props.signal && props.signal.value !== undefined;
68340
68573
  const HOURS = Array.from({
68341
68574
  length: HOUR_COUNT
68342
68575
  }, (_, hour) => hour);
68343
68576
  const padTwo = value => String(value).padStart(2, "0");
68344
68577
 
68578
+ // The two times as one span, { start, end } — the shape a pair carries.
68579
+ const aggregateSpan = childUIStateControllers => {
68580
+ const span = {};
68581
+ for (const child of childUIStateControllers) {
68582
+ if (child.name === "start" || child.name === "end") {
68583
+ span[child.name] = child.uiState;
68584
+ }
68585
+ }
68586
+ return span;
68587
+ };
68588
+
68345
68589
  // The two wheels as one value, "HH:MM".
68346
68590
  const aggregateTime = childUIStateControllers => {
68347
68591
  let hour = "";
@@ -74068,6 +74312,12 @@ const css = /* css */`
74068
74312
  * @param {object} props
74069
74313
  * @param {boolean} [props.open] - Controlled open state, forwarded as-is to
74070
74314
  * `Popup`'s own `open`.
74315
+ * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
74316
+ * state said the way every navi control says it: the panel opens and closes
74317
+ * to match the signal, and writes into it whenever it opens or closes on its
74318
+ * own (Escape, swipe, a --navi-close command) — one binding to both drive
74319
+ * the panel and know where it is. Forwarded as-is to `Popup`; excludes
74320
+ * `open` (see `Dialog`/`Popover`'s own `signal`).
74071
74321
  * @param {boolean} [props.defaultOpen] - Uncontrolled, mount-only initial
74072
74322
  * open state, forwarded as-is to `Popup`. Neither this nor `open` is
74073
74323
  * required at all for a purely command-driven panel (an `id` plus a
@@ -74134,6 +74384,7 @@ const css = /* css */`
74134
74384
  */
74135
74385
  const SidePanel = ({
74136
74386
  open,
74387
+ signal,
74137
74388
  defaultOpen,
74138
74389
  onClose,
74139
74390
  children,
@@ -74155,6 +74406,7 @@ const SidePanel = ({
74155
74406
  return jsx(Popup, {
74156
74407
  mode: mode,
74157
74408
  open: open,
74409
+ signal: signal,
74158
74410
  defaultOpen: defaultOpen,
74159
74411
  onClose: onClose,
74160
74412
  layer: layer,