@jsenv/navi 0.29.85 → 0.29.86

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.
@@ -15122,8 +15122,8 @@ const TIME_RANGE_CONSTRAINT = {
15122
15122
  console.warn(`Time after constraint: no control with id "${after}"`);
15123
15123
  return null;
15124
15124
  }
15125
- const timeBefore = minutesFromTime(otherController.uiState);
15126
- const timeAfter = minutesFromTime(field.uiState);
15125
+ const timeBefore = minutesFromTime$1(otherController.uiState);
15126
+ const timeAfter = minutesFromTime$1(field.uiState);
15127
15127
  if (timeBefore === null || timeAfter === null) {
15128
15128
  return null;
15129
15129
  }
@@ -15148,7 +15148,7 @@ CONSTRAINT_ATTRIBUTE_SET.add("data-time-min-duration");
15148
15148
 
15149
15149
  // "HH:MM" as a number of minutes, which is what two times are compared and
15150
15150
  // subtracted as. Anything else is a time nobody has finished writing.
15151
- const minutesFromTime = (time) => {
15151
+ const minutesFromTime$1 = (time) => {
15152
15152
  if (typeof time !== "string") {
15153
15153
  return null;
15154
15154
  }
@@ -25000,6 +25000,7 @@ const useUIStateController = (
25000
25000
  const controlType = controlInfo.controlType;
25001
25001
  const isRadio = controlType === "input" && props.type === "radio";
25002
25002
  const isProxy = Boolean(props["navi-control-proxy-for"]);
25003
+ const emptyUIState = resolveEmptyUIState(props, controlType);
25003
25004
 
25004
25005
  const scope = useRenderScope(
25005
25006
  // ── init: runs once on mount ───────────────────────────────────────────
@@ -25050,6 +25051,7 @@ const useUIStateController = (
25050
25051
  parentUiStateSignalHolder,
25051
25052
  isProxy,
25052
25053
  allowNameless,
25054
+ emptyUIState,
25053
25055
  // Set here too, not only in `update` below: a control rendered once and
25054
25056
  // never re-rendered would otherwise never say whether it was GIVEN a
25055
25057
  // value (`value`, or a bound signal with no default of its own) or is
@@ -25242,6 +25244,9 @@ const useUIStateController = (
25242
25244
  }
25243
25245
  debugUIState(e, `publishUIState(${JSON.stringify(newUIState)})`);
25244
25246
  publishUIState(newUIState, e);
25247
+ // A picker hands what it holds to the control in its popup — see
25248
+ // useUIFacadeStateController, which is what puts this here.
25249
+ controller.pushStateDownToFacadeChild?.(newUIState, e);
25245
25250
  const el = controller.ref.current;
25246
25251
  // Always notify the element that its UI state changed.
25247
25252
  // Listeners use this to stay in sync (e.g. input_effect.js tracks currentState,
@@ -25433,12 +25438,7 @@ const useUIStateController = (
25433
25438
  return true;
25434
25439
  },
25435
25440
  clearUIState: (e) => {
25436
- // Radio and checkbox "unchecked" state is `undefined`, not `""`.
25437
- // Passing `""` would set checked=true because `"" !== undefined`.
25438
- const isCheckable =
25439
- controlType === "input" &&
25440
- (props.type === "radio" || props.type === "checkbox");
25441
- controller.setUIState(isCheckable ? undefined : "", e);
25441
+ controller.setUIState(resolveClearedUIState(controller), e);
25442
25442
  },
25443
25443
  resetUIState: (e) => {
25444
25444
  controller.setUIState(controller.state, e);
@@ -25515,6 +25515,7 @@ const useUIStateController = (
25515
25515
  controller.ref = props.ref;
25516
25516
  controller.id = props.id; // never supposed to change, not supported for now
25517
25517
  controller.name = props.name;
25518
+ controller.emptyUIState = emptyUIState;
25518
25519
  controller.parentUIStateController = parentUIStateController;
25519
25520
  const {
25520
25521
  value,
@@ -25705,14 +25706,18 @@ const GROUP_DEFAULTS = {
25705
25706
  single: {
25706
25707
  // The same exclusions canRegisterAsFacadeChild already makes below (the
25707
25708
  // picker façade asked the very same question: which child IS the value).
25708
- // Buttons and links never hold one. And a control *carrying* navi-list is
25709
- // the search box driving some other list not the list itself, which stays
25710
- // a perfectly good single value here (one item, or the array a multiple
25711
- // list exposes). Excluding the searcher is what leaves the list alone.
25709
+ // Buttons and links never hold one, and neither does a control that
25710
+ // declared itself nameless. A control *carrying* navi-list is the search
25711
+ // box driving some other list not the list itself, which stays a
25712
+ // perfectly good single value here (one item, or the array a multiple list
25713
+ // exposes). Excluding the searcher is what leaves the list alone.
25712
25714
  childControlFilter: (child) => {
25713
25715
  if (child.controlType === "button" || child.controlType === "link") {
25714
25716
  return false;
25715
25717
  }
25718
+ if (child.allowNameless) {
25719
+ return false;
25720
+ }
25716
25721
  if (child.props?.["navi-list"]) {
25717
25722
  return false;
25718
25723
  }
@@ -25745,7 +25750,14 @@ const GROUP_DEFAULTS = {
25745
25750
  aggregateChildStates: (children) => {
25746
25751
  const groupValues = {};
25747
25752
  for (const child of children) {
25748
- const { name, uiState, allowNameless } = child;
25753
+ const { name, allowNameless, emptyUIState } = child;
25754
+ // A control holding nothing writes its own empty, not a hole: the key
25755
+ // is in the object either way, and what is read from it keeps the shape
25756
+ // the reader was promised (see resolveEmptyUIState).
25757
+ const uiState =
25758
+ child.uiState === undefined && emptyUIState !== undefined
25759
+ ? emptyUIState
25760
+ : child.uiState;
25749
25761
  if (!name) {
25750
25762
  // A nameless GROUP is a grouping, not a value: it exists to hold its
25751
25763
  // children together (a WheelGroup sharing navigation, a fieldset-ish
@@ -25993,6 +26005,25 @@ const useUIGroupStateController = (
25993
26005
  ref,
25994
26006
  getPropFromState: (uiState) => uiState,
25995
26007
  distributeChildUIState: resolvedDistributeChildUIState,
26008
+ // Where the group puts a value on ONE child: the only place that knows
26009
+ // what each child gets, and the only one that sees a child it cannot
26010
+ // place — see warnChildAnswersForItself.
26011
+ placeChildUIState: (childUIStateController, groupUIState, e) => {
26012
+ if (!shouldPropagateStateToChild(childUIStateController)) {
26013
+ return;
26014
+ }
26015
+ if (childUIStateController.hasStateProp) {
26016
+ return;
26017
+ }
26018
+ const childNewState = resolvedDistributeChildUIState(
26019
+ groupUIState,
26020
+ childUIStateController,
26021
+ );
26022
+ if (childNewState === CANNOT_DERIVE) {
26023
+ return;
26024
+ }
26025
+ childUIStateController.setUIState(childNewState, e);
26026
+ },
25996
26027
  setUIState: (newUIState, e) => {
25997
26028
  if (
25998
26029
  stateType === "object" &&
@@ -26025,14 +26056,9 @@ const useUIGroupStateController = (
26025
26056
  });
26026
26057
  chainEvent(propagateDownEvent, e);
26027
26058
  for (const childUIStateController of childUIStateControllerArray) {
26028
- if (!shouldPropagateStateToChild(childUIStateController)) continue;
26029
- const childNewState = resolvedDistributeChildUIState(
26030
- newUIState,
26059
+ controller.placeChildUIState(
26031
26060
  childUIStateController,
26032
- );
26033
- if (childNewState === CANNOT_DERIVE) continue;
26034
- childUIStateController.setUIState(
26035
- childNewState,
26061
+ newUIState,
26036
26062
  propagateDownEvent,
26037
26063
  );
26038
26064
  }
@@ -26091,27 +26117,17 @@ const useUIGroupStateController = (
26091
26117
  debugUIGroup(
26092
26118
  `${controlType}.registerChild("${childControlType}") -> registered (total: ${childUIStateControllerArray.length})`,
26093
26119
  );
26094
- if (!childUIStateController.hasStateProp) {
26120
+ if (controller.hasValueProp || controller.hasDefaultValueProp) {
26095
26121
  const initialEvent = new CustomEvent("initial_state_push", {
26096
26122
  detail: {},
26097
26123
  });
26098
- if (controller.hasValueProp) {
26099
- const childNewState = resolvedDistributeChildUIState(
26100
- controller.value,
26101
- childUIStateController,
26102
- );
26103
- if (childNewState !== CANNOT_DERIVE) {
26104
- childUIStateController.setUIState(childNewState, initialEvent);
26105
- }
26106
- } else if (controller.hasDefaultValueProp) {
26107
- const childNewState = resolvedDistributeChildUIState(
26108
- controller.defaultValue,
26109
- childUIStateController,
26110
- );
26111
- if (childNewState !== CANNOT_DERIVE) {
26112
- childUIStateController.setUIState(childNewState, initialEvent);
26113
- }
26114
- }
26124
+ controller.placeChildUIState(
26125
+ childUIStateController,
26126
+ controller.hasValueProp
26127
+ ? controller.value
26128
+ : controller.defaultValue,
26129
+ initialEvent,
26130
+ );
26115
26131
  }
26116
26132
  onChange(new CustomEvent(`${childControlType}_mount`), {
26117
26133
  notifyExternal: "silent",
@@ -26290,14 +26306,11 @@ const useUIGroupStateController = (
26290
26306
  { detail: {} },
26291
26307
  );
26292
26308
  for (const childUIStateController of childUIStateControllerArray) {
26293
- if (!shouldPropagateStateToChild(childUIStateController)) continue;
26294
- if (childUIStateController.hasStateProp) continue;
26295
- const childNewState = controller.distributeChildUIState(
26296
- value,
26309
+ controller.placeChildUIState(
26297
26310
  childUIStateController,
26311
+ value,
26312
+ propagateDownEvent,
26298
26313
  );
26299
- if (childNewState === CANNOT_DERIVE) continue;
26300
- childUIStateController.setUIState(childNewState, propagateDownEvent);
26301
26314
  }
26302
26315
  controller.syncInternalState(value);
26303
26316
  }
@@ -26315,14 +26328,11 @@ const useUIGroupStateController = (
26315
26328
  { detail: {} },
26316
26329
  );
26317
26330
  for (const childUIStateController of childUIStateControllerArray) {
26318
- if (!shouldPropagateStateToChild(childUIStateController)) continue;
26319
- if (childUIStateController.hasStateProp) continue;
26320
- const childNewState = controller.distributeChildUIState(
26321
- defaultValue,
26331
+ controller.placeChildUIState(
26322
26332
  childUIStateController,
26333
+ defaultValue,
26334
+ propagateDownEvent,
26323
26335
  );
26324
- if (childNewState === CANNOT_DERIVE) continue;
26325
- childUIStateController.setUIState(childNewState, propagateDownEvent);
26326
26336
  }
26327
26337
  controller.syncInternalState(defaultValue);
26328
26338
  }
@@ -26440,6 +26450,12 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26440
26450
  if (childController.controlType === "link") return false;
26441
26451
  if (childController.controlType === "facade") return false;
26442
26452
  if (childController.isProxy) return false;
26453
+ if (childController.allowNameless) {
26454
+ // A control saying it is not a field is not the one the picker talks
26455
+ // to: the search box above the list, the "select all" switch beside
26456
+ // it. It is there to help find the answer, not to be it.
26457
+ return false;
26458
+ }
26443
26459
  if (childController.props["navi-list"]) {
26444
26460
  // Controls with navi-list act as standalone list navigators and should
26445
26461
  // not be treated as the picker's synced child.
@@ -26448,9 +26464,38 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26448
26464
  return true;
26449
26465
  };
26450
26466
 
26467
+ // Picker → child. Handed to the real controller during THIS render so it
26468
+ // is in place before any layout effect runs: the value a parent form
26469
+ // distributes reaches its children from their registration effect, which
26470
+ // fires before the picker's own effects — a façade that only started
26471
+ // listening from an effect of its own was told nothing, and the popup
26472
+ // opened empty on a value the picker already held.
26473
+ const pushStateDownToChild = (newUIState, e) => {
26474
+ if (updatingRef.current) {
26475
+ return;
26476
+ }
26477
+ const child = firstChildControllerRef.current;
26478
+ if (!child) {
26479
+ return;
26480
+ }
26481
+ updatingRef.current = true;
26482
+ const propagateEventType =
26483
+ e.type === "initial_state_push"
26484
+ ? "initial_state_push"
26485
+ : "propagate_down_set_ui_state";
26486
+ const propagateDownEvent = new CustomEvent(propagateEventType, {
26487
+ detail: {},
26488
+ });
26489
+ chainEvent(propagateDownEvent, e);
26490
+ child.setUIState(newUIState, propagateDownEvent);
26491
+ updatingRef.current = false;
26492
+ };
26493
+ realUIStateController.pushStateDownToFacadeChild = pushStateDownToChild;
26494
+
26451
26495
  const facadeUIStateController = {
26452
26496
  controlType: "facade",
26453
26497
  props,
26498
+ pushStateDownToChild,
26454
26499
  ref: realUIStateController.ref,
26455
26500
  uiStateSignal: realUIStateController.uiStateSignal,
26456
26501
  controlHostProps: realUIStateController.controlHostProps,
@@ -26464,7 +26509,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26464
26509
  `[navi] a second control ("${childType}"${child.name ? ` name="${child.name}"` : ""}) registered in the ${describePicker(props)} popup. ` +
26465
26510
  `A picker talks to ONE control: the first one receives the picker's whole value and is the only one read back, ` +
26466
26511
  `so this one is neither filled nor collected. ` +
26467
- `A popup holding several values needs one group around them — wrap them in a <ControlGroup>, name each control inside it, and give the picker type="object".`,
26512
+ `A popup holding several values needs one group around them — wrap them in a <ControlGroup>, name each control inside it, and give the picker type="object". ` +
26513
+ `A control that is there to FIND the answer rather than be it (a search box, a "select all") says so with allowNameless and steps out of the way.`,
26468
26514
  child,
26469
26515
  );
26470
26516
  } else {
@@ -26564,6 +26610,8 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26564
26610
  s.controller.ref = realUIStateController.ref;
26565
26611
  s.controller.uiStateSignal = realUIStateController.uiStateSignal;
26566
26612
  s.controller.controlHostProps = realUIStateController.controlHostProps;
26613
+ realUIStateController.pushStateDownToFacadeChild =
26614
+ s.controller.pushStateDownToChild;
26567
26615
 
26568
26616
  return {
26569
26617
  realUIStateController,
@@ -26571,26 +26619,6 @@ const useUIFacadeStateController = (props, realUIStateController) => {
26571
26619
  },
26572
26620
  );
26573
26621
 
26574
- useLayoutEffect(() => {
26575
- return realUIStateController.subscribe((newUIState, e) => {
26576
- if (updatingRef.current) {
26577
- return;
26578
- }
26579
- const child = firstChildControllerRef.current;
26580
- if (!child) {
26581
- return;
26582
- }
26583
- updatingRef.current = true;
26584
- const propagateDownEvent = new CustomEvent(
26585
- "propagate_down_set_ui_state",
26586
- { detail: {} },
26587
- );
26588
- chainEvent(propagateDownEvent, e);
26589
- child.setUIState(newUIState, propagateDownEvent);
26590
- updatingRef.current = false;
26591
- });
26592
- }, [realUIStateController]);
26593
-
26594
26622
  return scope.controller;
26595
26623
  };
26596
26624
 
@@ -26685,6 +26713,60 @@ const dispatchSyntheticInput = (el, inputEvent, causeEvent) => {
26685
26713
  el.dispatchEvent(inputEvent);
26686
26714
  };
26687
26715
 
26716
+ /**
26717
+ * What a control is worth when it holds nothing — nothing, in the shape of the
26718
+ * question it answers. A list of days nobody picked is an empty list, not an
26719
+ * empty string; a yes/no nobody said yes to is `false`. Without this the shape
26720
+ * changes under the reader as soon as the answer is empty, and the conversion
26721
+ * back gets written after the wrong value has already been sent.
26722
+ *
26723
+ * `undefined` means the control has no empty of its own — it is simply not
26724
+ * there, which is what an untouched date or an unchecked radio is.
26725
+ */
26726
+ const resolveEmptyUIState = (props, controlType) => {
26727
+ if (controlType === "input" && props.type === "checkbox") {
26728
+ // A checkbox is a member of a set, the way HTML has it: checked it sends
26729
+ // its value ("on" by default), unchecked it sends nothing at all. Only one
26730
+ // holding `true` is a yes/no, and a yes/no nobody said yes to is `false`.
26731
+ return props.value === true ? false : undefined;
26732
+ }
26733
+ const stateShape = props["navi-state-shape"];
26734
+ if (stateShape === "array") {
26735
+ return EMPTY_ARRAY;
26736
+ }
26737
+ if (stateShape === "object") {
26738
+ return EMPTY_OBJECT;
26739
+ }
26740
+ return undefined;
26741
+ };
26742
+
26743
+ // What a cleared control shows: its own empty, kept in the shape it was holding.
26744
+ const resolveClearedUIState = (controller) => {
26745
+ const { controlType, props } = controller;
26746
+ if (
26747
+ controlType === "input" &&
26748
+ (props.type === "radio" || props.type === "checkbox")
26749
+ ) {
26750
+ // Unchecked is `undefined`: any other value reads as checked, `false`
26751
+ // included (see the `checked` line in control_hooks' toDomProps). What such
26752
+ // a control is worth once unchecked is `emptyUIState`, read where the value
26753
+ // is collected rather than stored here.
26754
+ return undefined;
26755
+ }
26756
+ const { emptyUIState } = controller;
26757
+ if (emptyUIState !== undefined) {
26758
+ return emptyUIState;
26759
+ }
26760
+ const currentUIState = controller.uiState;
26761
+ if (Array.isArray(currentUIState)) {
26762
+ return EMPTY_ARRAY;
26763
+ }
26764
+ if (currentUIState !== null && typeof currentUIState === "object") {
26765
+ return EMPTY_OBJECT;
26766
+ }
26767
+ return "";
26768
+ };
26769
+
26688
26770
  // What a control says when it has nothing to say: no value at all, or the empty
26689
26771
  // array/object a group falls back to while it has no child to aggregate.
26690
26772
  const uiStateHoldsNothing = (uiState) => {
@@ -50583,6 +50665,55 @@ const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
50583
50665
  return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
50584
50666
  };
50585
50667
 
50668
+ /**
50669
+ * "HH:MM" and its two numbers, in both directions — what any control made of an
50670
+ * hour beside a minute (fields, wheels) aggregates to and is placed from. Held
50671
+ * as numbers, written on two digits: how they are shown is each control's own
50672
+ * business.
50673
+ */
50674
+ const parseTimeParts = (time) => {
50675
+ if (typeof time !== "string") {
50676
+ return null;
50677
+ }
50678
+ const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
50679
+ if (!match) {
50680
+ return null;
50681
+ }
50682
+ return { hour: Number(match[1]), minute: Number(match[2]) };
50683
+ };
50684
+
50685
+ // Half a time is not a time: a control holding one of the two and nothing in
50686
+ // the other has no value at all, and a form has nothing to send about it.
50687
+ const formatTimeParts = (hour, minute) => {
50688
+ if (
50689
+ hour === "" ||
50690
+ hour === undefined ||
50691
+ minute === "" ||
50692
+ minute === undefined
50693
+ ) {
50694
+ return undefined;
50695
+ }
50696
+ return `${padTwo$1(hour)}:${padTwo$1(minute)}`;
50697
+ };
50698
+
50699
+ const minutesFromTime = (time) => {
50700
+ const parts = parseTimeParts(time);
50701
+ if (!parts) {
50702
+ return null;
50703
+ }
50704
+ return parts.hour * 60 + parts.minute;
50705
+ };
50706
+
50707
+ const timeFromMinutes = (minutes) => {
50708
+ const inDay =
50709
+ ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
50710
+ return `${padTwo$1(Math.floor(inDay / 60))}:${padTwo$1(inDay % 60)}`;
50711
+ };
50712
+
50713
+ const MINUTES_PER_DAY = 24 * 60;
50714
+
50715
+ const padTwo$1 = (value) => String(value).padStart(2, "0");
50716
+
50586
50717
  // Maps validity type names → navi input type names.
50587
50718
  // Numeric signal types must not fall through to the native type="number"
50588
50719
  // (which adds spinner buttons and has poor UX) — they map to navi_number instead.
@@ -62007,8 +62138,7 @@ const PickerNaviTime = props => {
62007
62138
  const {
62008
62139
  min = "00:00",
62009
62140
  max = "23:30",
62010
- step,
62011
- value
62141
+ step
62012
62142
  } = props;
62013
62143
  const stepSeconds = timeStringToSeconds(step) ?? 1800;
62014
62144
  const slots = useMemo(() => generateTimeSlots(min, max, stepSeconds), [min, max, stepSeconds]);
@@ -62023,7 +62153,6 @@ const PickerNaviTime = props => {
62023
62153
  id: slot,
62024
62154
  index: i,
62025
62155
  value: slot,
62026
- selected: value === slot,
62027
62156
  children: jsx(Time, {
62028
62157
  type: "time",
62029
62158
  children: slot
@@ -62522,7 +62651,8 @@ const PickerObject = props => {
62522
62651
  return jsx(Next, {
62523
62652
  ui: jsx(PickerObjectUI, {}),
62524
62653
  ...props,
62525
- type: "navi_js"
62654
+ type: "navi_js",
62655
+ "navi-state-shape": "object"
62526
62656
  });
62527
62657
  };
62528
62658
  const PickerObjectUI = () => {
@@ -62557,7 +62687,8 @@ const PickerArray = props => {
62557
62687
  maxLines: "3",
62558
62688
  ui: jsx(PickerArrayUI, {}),
62559
62689
  ...props,
62560
- type: "navi_js"
62690
+ type: "navi_js",
62691
+ "navi-state-shape": "array"
62561
62692
  });
62562
62693
  };
62563
62694
  const PickerArrayUI = () => {
@@ -65031,8 +65162,8 @@ const TimeSpin = ({
65031
65162
  minuteLabel = naviI18n("time.minute_label"),
65032
65163
  ...rest
65033
65164
  }) => jsxs(SpinGroup, {
65034
- aggregateChildStates: aggregateTime,
65035
- distributeChildUIState: distributeTime,
65165
+ aggregateChildStates: aggregateTime$1,
65166
+ distributeChildUIState: distributeTime$1,
65036
65167
  ...rest,
65037
65168
  children: [jsx(NumberSpin, {
65038
65169
  name: "hour",
@@ -65058,9 +65189,8 @@ const TimeSpin = ({
65058
65189
  })]
65059
65190
  });
65060
65191
 
65061
- // The two fields as one value, "HH:MM" — and nothing at all while one of them
65062
- // is empty: half a time is not a time, and a form has nothing to send about it.
65063
- const aggregateTime = childUIStateControllers => {
65192
+ // The two fields as one value, "HH:MM".
65193
+ const aggregateTime$1 = childUIStateControllers => {
65064
65194
  let hour = "";
65065
65195
  let minute = "";
65066
65196
  for (const child of childUIStateControllers) {
@@ -65071,37 +65201,18 @@ const aggregateTime = childUIStateControllers => {
65071
65201
  minute = child.uiState ?? "";
65072
65202
  }
65073
65203
  }
65074
- if (hour === "" || minute === "") {
65075
- return undefined;
65076
- }
65077
- return `${padTwo(hour)}:${padTwo(minute)}`;
65204
+ return formatTimeParts(hour, minute);
65078
65205
  };
65079
65206
 
65080
65207
  // The way back: what the group is set to (a picked value, a form being reset)
65081
65208
  // lands on the field it belongs to.
65082
- const distributeTime = (groupState, childUIStateController) => {
65083
- const parts = parseTime(groupState);
65209
+ const distributeTime$1 = (groupState, childUIStateController) => {
65210
+ const parts = parseTimeParts(groupState);
65084
65211
  if (!parts) {
65085
65212
  return undefined;
65086
65213
  }
65087
65214
  return parts[childUIStateController.name];
65088
65215
  };
65089
- const parseTime = time => {
65090
- if (typeof time !== "string") {
65091
- return null;
65092
- }
65093
- const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
65094
- if (!match) {
65095
- return null;
65096
- }
65097
- // Numbers: what an hour and a minute are held as. How they are written —
65098
- // "07" — is the field's business (see NumberSpin's `pad`).
65099
- return {
65100
- hour: Number(match[1]),
65101
- minute: Number(match[2])
65102
- };
65103
- };
65104
- const padTwo = value => String(value).padStart(2, "0");
65105
65216
 
65106
65217
  /**
65107
65218
  * @type {import("ignore:preact").FunctionComponent<{
@@ -66658,7 +66769,6 @@ const SplitButton = props => {
66658
66769
  id: `${menuId}_${index}`,
66659
66770
  index: index,
66660
66771
  value: optionValue,
66661
- selected: optionValue === valueResolved,
66662
66772
  padding: "s",
66663
66773
  spacing: "s",
66664
66774
  ...optionRest,
@@ -69352,6 +69462,258 @@ const WheelColon = props => {
69352
69462
  };
69353
69463
  Wheel.Colon = WheelColon;
69354
69464
 
69465
+ /**
69466
+ * A time of day, and a span between two of them, set by turning rather than by
69467
+ * typing. A wheel only ever shows values that exist: there is no half-written
69468
+ * hour to bound and correct under the fingers, which is what a time typed digit
69469
+ * by digit puts a field through ("1" on its way to "18").
69470
+ *
69471
+ * `TimeWheel` is a `WheelGroup` of two `Wheel`s and carries a single "HH:MM",
69472
+ * like `TimeSpin` — the two are interchangeable in a form. `TimeRangeWheel` is
69473
+ * two of those and carries `{ start, end }`, with the rule such a pair always
69474
+ * has: the end comes after the start. Here that rule is lived rather than
69475
+ * checked — the bounds push each other while they turn, so what the wheels show
69476
+ * is always a span. The send-time constraint stays underneath for what pushing
69477
+ * cannot fix (a start so late the span no longer fits in the day).
69478
+ */
69479
+
69480
+ const HOUR_COUNT = 24;
69481
+ const MINUTES_PER_HOUR = 60;
69482
+ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
69483
+
69484
+ /**
69485
+ * @type {import("ignore:preact").FunctionComponent<{
69486
+ * name?: string,
69487
+ * value?: string,
69488
+ * defaultValue?: string,
69489
+ * signal?: import("@preact/signals").Signal<string>,
69490
+ * minuteStep?: number,
69491
+ * loop?: boolean,
69492
+ * separator?: import("ignore:preact").ComponentChildren,
69493
+ * hourLabel?: string,
69494
+ * minuteLabel?: string,
69495
+ * size?: string,
69496
+ * visibleCount?: number,
69497
+ * wheelProps?: object,
69498
+ * [key: string]: any,
69499
+ * }>}
69500
+ * @param {string} [value] The time shown, as "HH:MM".
69501
+ * @param {number} [minuteStep=1] How many minutes apart the values on the
69502
+ * minute wheel are — 15 for quarters of an hour.
69503
+ * @param {boolean} [loop=true] The wheels go round: 23h then 0h, 59 minutes
69504
+ * then 0. What a clock does. Say `loop={false}` for two ends one cannot turn
69505
+ * past.
69506
+ * @param {import("ignore:preact").ComponentChildren} [separator] What is written
69507
+ * between the hours and the minutes. "h" in French, ":" elsewhere.
69508
+ * @param {object} [wheelProps] Anything a `Wheel` takes, said once for both of
69509
+ * them — `visibleCount`, `itemWidth`, `glideSpeed`.
69510
+ */
69511
+ const TimeWheel = ({
69512
+ minuteStep = 1,
69513
+ loop = true,
69514
+ separator = naviI18n("time.hour_separator"),
69515
+ hourLabel = naviI18n("time.hour_label"),
69516
+ minuteLabel = naviI18n("time.minute_label"),
69517
+ size,
69518
+ wheelProps,
69519
+ ...rest
69520
+ }) => {
69521
+ const minutes = useMemo(() => {
69522
+ const minuteList = [];
69523
+ let minute = 0;
69524
+ while (minute < MINUTES_PER_HOUR) {
69525
+ minuteList.push(minute);
69526
+ minute += minuteStep;
69527
+ }
69528
+ return minuteList;
69529
+ }, [minuteStep]);
69530
+ return jsxs(WheelGroup, {
69531
+ aggregateChildStates: aggregateTime,
69532
+ distributeChildUIState: distributeTime,
69533
+ ...rest,
69534
+ children: [jsx(Wheel, {
69535
+ name: "hour",
69536
+ type: "integer",
69537
+ bounded: !loop,
69538
+ size: size,
69539
+ "aria-label": hourLabel,
69540
+ ...wheelProps,
69541
+ children: HOURS.map(hour => jsx(Wheel.Item, {
69542
+ value: hour,
69543
+ paddingX: "s",
69544
+ children: padTwo(hour)
69545
+ }, hour))
69546
+ }), jsx(WheelGroup.Separator, {
69547
+ size: size,
69548
+ children: separator
69549
+ }), jsx(Wheel, {
69550
+ name: "minute",
69551
+ type: "integer",
69552
+ bounded: !loop,
69553
+ size: size,
69554
+ "aria-label": minuteLabel,
69555
+ ...wheelProps,
69556
+ children: minutes.map(minute => jsx(Wheel.Item, {
69557
+ value: minute,
69558
+ paddingX: "s",
69559
+ children: padTwo(minute)
69560
+ }, minute))
69561
+ })]
69562
+ });
69563
+ };
69564
+
69565
+ /**
69566
+ * @type {import("ignore:preact").FunctionComponent<{
69567
+ * name?: string,
69568
+ * value?: { start?: string, end?: string },
69569
+ * defaultValue?: { start?: string, end?: string },
69570
+ * signal?: import("@preact/signals").Signal<{ start?: string, end?: string }>,
69571
+ * minuteStep?: number,
69572
+ * minDuration?: number,
69573
+ * loop?: boolean,
69574
+ * size?: string,
69575
+ * startLabel?: import("ignore:preact").ComponentChildren,
69576
+ * endLabel?: import("ignore:preact").ComponentChildren,
69577
+ * timeProps?: object,
69578
+ * [key: string]: any,
69579
+ * }>}
69580
+ * @param {{ start?: string, end?: string }} [value] The span shown, as two
69581
+ * "HH:MM".
69582
+ * @param {import("ignore:preact").ComponentChildren} [startLabel] What is written
69583
+ * before the first time ("De"), and `endLabel` between the two ("à"). Say
69584
+ * `null` for neither.
69585
+ * @param {number} [minuteStep=1] How many minutes apart the values on both
69586
+ * minute wheels are.
69587
+ * @param {number} [minDuration=0] How long the span must last at least, in
69588
+ * minutes. Zero by default: a span of no length is a span all the same, only
69589
+ * one that goes backwards is not. It is what the bounds keep between them as
69590
+ * they turn — turn the start into the end and the end moves along, keeping
69591
+ * that much room.
69592
+ * @param {object} [timeProps] Anything a `TimeWheel` takes, said once for both
69593
+ * of them. `startTimeProps`/`endTimeProps` say it to one of the two, and win
69594
+ * over this one.
69595
+ */
69596
+ const TimeRangeWheel = ({
69597
+ minuteStep = 1,
69598
+ minDuration = 0,
69599
+ loop = true,
69600
+ size,
69601
+ startLabel = naviI18n("time_range.from"),
69602
+ endLabel = naviI18n("time_range.to"),
69603
+ timeProps,
69604
+ startTimeProps,
69605
+ endTimeProps,
69606
+ ...rest
69607
+ }) => {
69608
+ const startId = useId();
69609
+ const startRef = useRef(null);
69610
+ const endRef = useRef(null);
69611
+
69612
+ // What the pair does while it is being turned: the bound that just moved is
69613
+ // the one the user is holding, so it stays where it was put and the OTHER one
69614
+ // gives way. A refusal at the end of the gesture would leave the person to
69615
+ // undo what they just did.
69616
+ const keepBoundsApart = (movedSide, movedTime, e) => {
69617
+ const movedMinutes = minutesFromTime(movedTime);
69618
+ if (movedMinutes === null) {
69619
+ return;
69620
+ }
69621
+ const otherEl = movedSide === "start" ? endRef.current : startRef.current;
69622
+ if (!otherEl) {
69623
+ return;
69624
+ }
69625
+ const otherMinutes = minutesFromTime(getUIStateFromElement(otherEl));
69626
+ if (otherMinutes === null) {
69627
+ return;
69628
+ }
69629
+ const duration = movedSide === "start" ? otherMinutes - movedMinutes : movedMinutes - otherMinutes;
69630
+ if (duration >= minDuration) {
69631
+ return;
69632
+ }
69633
+ let pushedMinutes = movedSide === "start" ? movedMinutes + minDuration : movedMinutes - minDuration;
69634
+ // The day has ends the wheels do not: pushed past midnight, the other bound
69635
+ // would come back round on the wrong side of the one that pushed it. It
69636
+ // stops at the edge instead, and the span that no longer fits is what the
69637
+ // send-time constraint is there to say (see time_range_constraint.js).
69638
+ if (pushedMinutes < 0) {
69639
+ pushedMinutes = 0;
69640
+ } else if (pushedMinutes > LAST_MINUTE_OF_DAY) {
69641
+ pushedMinutes = LAST_MINUTE_OF_DAY;
69642
+ }
69643
+ dispatchRequestSetUIState(otherEl, timeFromMinutes(pushedMinutes), {
69644
+ event: e
69645
+ });
69646
+ };
69647
+ return jsxs(ControlGroup, {
69648
+ flex: true,
69649
+ alignY: "center",
69650
+ spacing: "s",
69651
+ size: size,
69652
+ ...rest,
69653
+ children: [startLabel === null ? null : jsx(Text, {
69654
+ size: size,
69655
+ children: startLabel
69656
+ }), jsx(TimeWheel, {
69657
+ id: startId,
69658
+ ref: startRef,
69659
+ name: "start",
69660
+ minuteStep: minuteStep,
69661
+ loop: loop,
69662
+ size: size,
69663
+ uiAction: (value, e) => keepBoundsApart("start", value, e),
69664
+ ...timeProps,
69665
+ ...startTimeProps
69666
+ }), endLabel === null ? null : jsx(Text, {
69667
+ size: size,
69668
+ children: endLabel
69669
+ }), jsx(TimeWheel, {
69670
+ ref: endRef,
69671
+ name: "end",
69672
+ minuteStep: minuteStep,
69673
+ loop: loop,
69674
+ size: size,
69675
+ uiAction: (value, e) => keepBoundsApart("end", value, e)
69676
+ // Which time it comes after, and how much room there must be between
69677
+ // the two: said on the LATER of the two, so the answer is given where
69678
+ // the time one would have to move is (see time_range_constraint.js).
69679
+ ,
69680
+ "data-time-after": startId,
69681
+ "data-time-min-duration": minDuration,
69682
+ ...timeProps,
69683
+ ...endTimeProps
69684
+ })]
69685
+ });
69686
+ };
69687
+ const HOURS = Array.from({
69688
+ length: HOUR_COUNT
69689
+ }, (_, hour) => hour);
69690
+ const padTwo = value => String(value).padStart(2, "0");
69691
+
69692
+ // The two wheels as one value, "HH:MM".
69693
+ const aggregateTime = childUIStateControllers => {
69694
+ let hour = "";
69695
+ let minute = "";
69696
+ for (const child of childUIStateControllers) {
69697
+ if (child.name === "hour") {
69698
+ hour = child.uiState ?? "";
69699
+ }
69700
+ if (child.name === "minute") {
69701
+ minute = child.uiState ?? "";
69702
+ }
69703
+ }
69704
+ return formatTimeParts(hour, minute);
69705
+ };
69706
+
69707
+ // The way back: what the group is set to (a value given to it, a form being
69708
+ // reset, the other bound pushing it) lands on the wheel it belongs to.
69709
+ const distributeTime = (groupState, childUIStateController) => {
69710
+ const parts = parseTimeParts(groupState);
69711
+ if (!parts) {
69712
+ return undefined;
69713
+ }
69714
+ return parts[childUIStateController.name];
69715
+ };
69716
+
69355
69717
  const TableSelectionContext = createContext();
69356
69718
  const useTableSelectionContextValue = (
69357
69719
  selection,
@@ -75378,5 +75740,5 @@ const UserSvg = () => jsx("svg", {
75378
75740
  })
75379
75741
  });
75380
75742
 
75381
- 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, TimeSpin, 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 };
75743
+ 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 };
75382
75744
  //# sourceMappingURL=jsenv_navi.js.map