@jsenv/navi 0.29.88 → 0.29.89

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,
@@ -24731,6 +24782,7 @@ const useUIGroupStateController = (
24731
24782
  );
24732
24783
  return;
24733
24784
  }
24785
+ controller.stateGivenFromAbove = true;
24734
24786
  const propagateEventType =
24735
24787
  e.type === "initial_state_push"
24736
24788
  ? "initial_state_push"
@@ -24746,9 +24798,10 @@ const useUIGroupStateController = (
24746
24798
  propagateDownEvent,
24747
24799
  );
24748
24800
  }
24749
- const groupUIState = aggregateGroupUIState();
24801
+ const groupUIState = aggregateGroupUIState(newUIState);
24750
24802
  if (e.type === "initial_state_push") {
24751
24803
  controller.syncInternalState(groupUIState);
24804
+ writeBoundSignal(groupUIState);
24752
24805
  return;
24753
24806
  }
24754
24807
  applyState(groupUIState, e, { internalBehavior: true });
@@ -24800,15 +24853,28 @@ const useUIGroupStateController = (
24800
24853
  debugUIGroup(
24801
24854
  `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
24802
24855
  );
24803
- if (controller.hasValueProp || controller.hasDefaultValueProp) {
24856
+ const stateToPlaceChildFrom = controller.hasValueProp
24857
+ ? controller.value
24858
+ : controller.hasDefaultValueProp
24859
+ ? controller.defaultValue
24860
+ : // What the group HOLDS, for a child arriving after the value
24861
+ // did: a list item loaded later, a row scrolled back into a
24862
+ // virtualized list, a popup built at open. Two conditions, and
24863
+ // both are about not overwriting an answer with a silence — the
24864
+ // group must actually hold something, and the child must have
24865
+ // nothing of its own to show (one arriving with its own default
24866
+ // is answering, and the group is what its answers add up to).
24867
+ uiStateHoldsNothing(controller.uiState) ||
24868
+ !uiStateHoldsNothing(childUIStateController.uiState)
24869
+ ? undefined
24870
+ : controller.uiState;
24871
+ if (stateToPlaceChildFrom !== undefined) {
24804
24872
  const initialEvent = new CustomEvent("initial_state_push", {
24805
24873
  detail: {},
24806
24874
  });
24807
24875
  controller.placeChildUIState(
24808
24876
  childUIStateController,
24809
- controller.hasValueProp
24810
- ? controller.value
24811
- : controller.defaultValue,
24877
+ stateToPlaceChildFrom,
24812
24878
  initialEvent,
24813
24879
  );
24814
24880
  }
@@ -25097,6 +25163,7 @@ const EMPTY_OBJECT = {};
25097
25163
  */
25098
25164
  const useUIFacadeStateController = (props, realUIStateController) => {
25099
25165
  const firstChildControllerRef = useRef(null);
25166
+ const namelessChildSetRef = useRef(new Set());
25100
25167
  const updatingRef = useRef(false);
25101
25168
  const debugPopup = useDebugPopup();
25102
25169
  const debugInteraction = useDebugInteraction();
@@ -25128,6 +25195,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25128
25195
  // A control saying it is not a field is not the one the picker talks
25129
25196
  // to: the search box above the list, the "select all" switch beside
25130
25197
  // it. It is there to help find the answer, not to be it.
25198
+ namelessChildSetRef.current.add(childController);
25131
25199
  return false;
25132
25200
  }
25133
25201
  if (childController.props["navi-list"]) {
@@ -25150,6 +25218,10 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25150
25218
  }
25151
25219
  const child = firstChildControllerRef.current;
25152
25220
  if (!child) {
25221
+ warnPopupHasNothingButNamelessControls(
25222
+ props,
25223
+ namelessChildSetRef.current,
25224
+ );
25153
25225
  return;
25154
25226
  }
25155
25227
  updatingRef.current = true;
@@ -25298,6 +25370,11 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25298
25370
 
25299
25371
  const describePicker = (props) =>
25300
25372
  `<Picker${props.name ? ` name="${props.name}"` : ""}${props.type ? ` type="${props.type}"` : ""}>`;
25373
+ const warnPopupHasNothingButNamelessControls = (props, namelessChildSet) => {
25374
+ {
25375
+ return;
25376
+ }
25377
+ };
25301
25378
 
25302
25379
  /**
25303
25380
  * Returns true when `e` should trigger parent notification (child → parent bubbling).
@@ -68158,12 +68235,19 @@ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
68158
68235
  * past.
68159
68236
  * @param {import("ignore:preact").ComponentChildren} [separator] What is written
68160
68237
  * between the hours and the minutes. "h" in French, ":" elsewhere.
68238
+ * @param {string} [placeholder] What the wheels show while the time holds
68239
+ * nothing, as "HH:MM". Wheels have no blank row to land on, so their
68240
+ * placeholder is a position rather than a grey word — shown, but not an
68241
+ * answer: the value stays `undefined` until a wheel is turned or a real value
68242
+ * arrives. `defaultValue` is the other half of the pair — a time that IS the
68243
+ * answer, and where a reset goes back to.
68161
68244
  * @param {object} [wheelProps] Anything a `Wheel` takes, said once for both of
68162
68245
  * them — `visibleCount`, `itemWidth`, `glideSpeed`.
68163
68246
  */
68164
68247
  const TimeWheel = ({
68165
68248
  minuteStep = 1,
68166
68249
  loop = true,
68250
+ placeholder,
68167
68251
  separator = naviI18n("time.hour_separator"),
68168
68252
  hourLabel = naviI18n("time.hour_label"),
68169
68253
  minuteLabel = naviI18n("time.minute_label"),
@@ -68180,8 +68264,12 @@ const TimeWheel = ({
68180
68264
  }
68181
68265
  return minuteList;
68182
68266
  }, [minuteStep]);
68267
+ const {
68268
+ aggregateChildStates
68269
+ } = useAnswered(placeholder, rest, aggregateTime);
68270
+ const placeholderParts = parseTimeParts(placeholder);
68183
68271
  return jsxs(WheelGroup, {
68184
- aggregateChildStates: aggregateTime,
68272
+ aggregateChildStates: aggregateChildStates,
68185
68273
  distributeChildUIState: distributeTime,
68186
68274
  ...rest,
68187
68275
  children: [jsx(Wheel, {
@@ -68190,6 +68278,7 @@ const TimeWheel = ({
68190
68278
  bounded: !loop,
68191
68279
  size: size,
68192
68280
  "aria-label": hourLabel,
68281
+ defaultValue: placeholderParts ? placeholderParts.hour : undefined,
68193
68282
  ...wheelProps,
68194
68283
  children: HOURS.map(hour => jsx(Wheel.Item, {
68195
68284
  value: hour,
@@ -68205,6 +68294,7 @@ const TimeWheel = ({
68205
68294
  bounded: !loop,
68206
68295
  size: size,
68207
68296
  "aria-label": minuteLabel,
68297
+ defaultValue: placeholderParts ? placeholderParts.minute : undefined,
68208
68298
  ...wheelProps,
68209
68299
  children: minutes.map(minute => jsx(Wheel.Item, {
68210
68300
  value: minute,
@@ -68242,6 +68332,12 @@ const TimeWheel = ({
68242
68332
  * one that goes backwards is not. It is what the bounds keep between them as
68243
68333
  * they turn — turn the start into the end and the end moves along, keeping
68244
68334
  * that much room.
68335
+ * @param {{ start?: string, end?: string }} [placeholder] What the two wheels
68336
+ * show while the span holds nothing — a position, since wheels have no blank
68337
+ * row to land on, and not an answer: the value stays `undefined` until one of
68338
+ * them is turned. One turn settles both, the untouched bound included, left
68339
+ * where the placeholder put it. For a span that is optional ("any time of
68340
+ * day") and still has to show hours.
68245
68341
  * @param {object} [timeProps] Anything a `TimeWheel` takes, said once for both
68246
68342
  * of them. `startTimeProps`/`endTimeProps` say it to one of the two, and win
68247
68343
  * over this one.
@@ -68250,6 +68346,7 @@ const TimeRangeWheel = ({
68250
68346
  minuteStep = 1,
68251
68347
  minDuration = 0,
68252
68348
  loop = true,
68349
+ placeholder,
68253
68350
  size,
68254
68351
  startLabel = naviI18n("time_range.from"),
68255
68352
  endLabel = naviI18n("time_range.to"),
@@ -68261,6 +68358,12 @@ const TimeRangeWheel = ({
68261
68358
  const startId = useId();
68262
68359
  const startRef = useRef(null);
68263
68360
  const endRef = useRef(null);
68361
+ // One turn settles the whole span: a start somebody chose makes the end an
68362
+ // answer too, left where the placeholder put it.
68363
+ const {
68364
+ answeredRef,
68365
+ aggregateChildStates
68366
+ } = useAnswered(placeholder, rest, aggregateSpan);
68264
68367
 
68265
68368
  // What the pair does while it is being turned: the bound that just moved is
68266
68369
  // the one the user is holding, so it stays where it was put and the OTHER one
@@ -68297,51 +68400,127 @@ const TimeRangeWheel = ({
68297
68400
  event: e
68298
68401
  });
68299
68402
  };
68300
- return jsxs(ControlGroup, {
68403
+ return jsx(ControlGroup, {
68301
68404
  flex: true,
68302
68405
  alignY: "center",
68303
68406
  spacing: "s",
68304
68407
  size: size,
68408
+ aggregateChildStates: aggregateChildStates,
68305
68409
  ...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
- })]
68410
+ children: jsxs(AnsweredContext.Provider, {
68411
+ value: answeredRef,
68412
+ children: [startLabel === null ? null : jsx(Text, {
68413
+ size: size,
68414
+ children: startLabel
68415
+ }), jsx(TimeWheel, {
68416
+ id: startId,
68417
+ ref: startRef,
68418
+ name: "start",
68419
+ minuteStep: minuteStep,
68420
+ loop: loop,
68421
+ size: size,
68422
+ placeholder: placeholder ? placeholder.start : undefined,
68423
+ uiAction: (value, e) => keepBoundsApart("start", value, e),
68424
+ ...timeProps,
68425
+ ...startTimeProps
68426
+ }), endLabel === null ? null : jsx(Text, {
68427
+ size: size,
68428
+ children: endLabel
68429
+ }), jsx(TimeWheel, {
68430
+ ref: endRef,
68431
+ name: "end",
68432
+ minuteStep: minuteStep,
68433
+ loop: loop,
68434
+ size: size,
68435
+ placeholder: placeholder ? placeholder.end : undefined,
68436
+ uiAction: (value, e) => keepBoundsApart("end", value, e)
68437
+ // Which time it comes after, and how much room there must be between
68438
+ // the two: said on the LATER of the two, so the answer is given where
68439
+ // the time one would have to move is (see time_range_constraint.js).
68440
+ ,
68441
+ "data-time-after": startId,
68442
+ "data-time-min-duration": minDuration,
68443
+ ...timeProps,
68444
+ ...endTimeProps
68445
+ })]
68446
+ })
68338
68447
  });
68339
68448
  };
68449
+
68450
+ /**
68451
+ * Wheels always show something — there is no blank row to land on — so a pair of
68452
+ * them cannot say "nothing set" by looking empty. Their `placeholder` is
68453
+ * therefore a position rather than a grey word: shown like a value, and not one.
68454
+ * The value stays `undefined` until a wheel is turned, which is what tells "any
68455
+ * time of day" from a span somebody chose. `defaultValue` remains what it is
68456
+ * everywhere else — a time that IS the answer.
68457
+ *
68458
+ * What counts as turned is read from the value itself rather than from a
68459
+ * gesture: while nothing has moved off the placeholder, nothing is set; the
68460
+ * moment it differs, it is an answer and stays one, even turned back onto the
68461
+ * placeholder. A wheel's own `uiAction` runs after its group has aggregated, so
68462
+ * a flag set from there would always be one turn late.
68463
+ *
68464
+ * The flag is a ref rather than state because the aggregate a group is created
68465
+ * with is the one it keeps: swapping the function on a later render changes
68466
+ * nothing (see useUIGroupStateController). One stable function reading one ref.
68467
+ *
68468
+ * A pair shares ONE flag through `AnsweredContext`: turning the start settles
68469
+ * the end too, left where the placeholder put it. Each of the two times gating
68470
+ * on its own would answer half a span — and would leave the pair nothing to
68471
+ * compare its own placeholder against.
68472
+ */
68473
+ const AnsweredContext = createContext(null);
68474
+ const useAnswered = (placeholder, props, aggregateWhenAnswered) => {
68475
+ const answeredFromPair = useContext(AnsweredContext);
68476
+ const ownAnsweredRef = useRef(false);
68477
+ const answeredRef = answeredFromPair || ownAnsweredRef;
68478
+ if (!placeholder || isAnswerGivenByProps(props)) {
68479
+ answeredRef.current = true;
68480
+ }
68481
+ // Inside a pair, only the pair decides: a time that gated on its own would
68482
+ // hand the pair nothing to compare, and half a span cannot be read.
68483
+ const gates = !answeredFromPair;
68484
+ const placeholderRef = useRef(placeholder);
68485
+ placeholderRef.current = placeholder;
68486
+ const aggregateRef = useRef(null);
68487
+ if (!aggregateRef.current) {
68488
+ aggregateRef.current = children => {
68489
+ const aggregated = aggregateWhenAnswered(children);
68490
+ if (answeredRef.current) {
68491
+ return aggregated;
68492
+ }
68493
+ if (compareTwoJsValues(aggregated, placeholderRef.current)) {
68494
+ return undefined;
68495
+ }
68496
+ // It moved: from here on this is an answer, and stays one even when it is
68497
+ // turned back onto the placeholder — somebody chose that time.
68498
+ answeredRef.current = true;
68499
+ return aggregated;
68500
+ };
68501
+ }
68502
+ return {
68503
+ answeredRef,
68504
+ aggregateChildStates: gates ? aggregateRef.current : aggregateWhenAnswered
68505
+ };
68506
+ };
68507
+ const isAnswerGivenByProps = props => props.value !== undefined || props.defaultValue !== undefined || props.signal && props.signal.value !== undefined;
68340
68508
  const HOURS = Array.from({
68341
68509
  length: HOUR_COUNT
68342
68510
  }, (_, hour) => hour);
68343
68511
  const padTwo = value => String(value).padStart(2, "0");
68344
68512
 
68513
+ // The two times as one span, { start, end } — the shape a pair carries.
68514
+ const aggregateSpan = childUIStateControllers => {
68515
+ const span = {};
68516
+ for (const child of childUIStateControllers) {
68517
+ if (child.name === "start" || child.name === "end") {
68518
+ span[child.name] = child.uiState;
68519
+ }
68520
+ }
68521
+ return span;
68522
+ };
68523
+
68345
68524
  // The two wheels as one value, "HH:MM".
68346
68525
  const aggregateTime = childUIStateControllers => {
68347
68526
  let hour = "";