@jsenv/navi 0.29.32 → 0.29.34

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.
@@ -1459,6 +1459,31 @@ naviI18n.addAll({
1459
1459
  },
1460
1460
  });
1461
1461
 
1462
+ // Time spin messages — what a clock writes between an hour and its minutes,
1463
+ // and how the two ends of a span are named.
1464
+ naviI18n.addAll({
1465
+ "time.hour_separator": {
1466
+ en: ":",
1467
+ fr: "h",
1468
+ },
1469
+ "time.hour_label": {
1470
+ en: "Hours",
1471
+ fr: "Heures",
1472
+ },
1473
+ "time.minute_label": {
1474
+ en: "Minutes",
1475
+ fr: "Minutes",
1476
+ },
1477
+ "time_range.from": {
1478
+ en: "From",
1479
+ fr: "De",
1480
+ },
1481
+ "time_range.to": {
1482
+ en: "to",
1483
+ fr: "à",
1484
+ },
1485
+ });
1486
+
1462
1487
  // List messages — override any key to customize list messages
1463
1488
  naviI18n.addAll({
1464
1489
  "list.empty": {
@@ -1605,6 +1630,14 @@ naviI18n.addAll({
1605
1630
  fr: "Ce champ doit être identique au précédent.",
1606
1631
  en: "This field must match the previous one.",
1607
1632
  },
1633
+ "constraint.time_after.default": {
1634
+ fr: "L'heure de fin ne peut pas être avant l'heure de début.",
1635
+ en: "The end time cannot be before the start time.",
1636
+ },
1637
+ "constraint.time_after.min_duration": {
1638
+ fr: "La plage doit durer au moins <strong>[duration]</strong> minutes.",
1639
+ en: "The span must last at least <strong>[duration]</strong> minutes.",
1640
+ },
1608
1641
  "constraint.required.checkbox": {
1609
1642
  fr: "Veuillez cocher cette case.",
1610
1643
  en: "Please check this box.",
@@ -2258,6 +2291,9 @@ const generateSignalId = () => {
2258
2291
  * @param {any} [options.default] - Static fallback value used when defaultValue is a signal and that signal's value is undefined
2259
2292
  * @param {boolean} [options.persists=false] - Whether to persist the signal value in localStorage using the signal ID as key
2260
2293
  * @param {"string" | "number" | "boolean" | "object"} [options.type="string"] - Type for localStorage serialization/deserialization
2294
+ * @param {"string" | "number" | "boolean"} [options.itemType] - For array type: type of the array items.
2295
+ * Used when reading the value back from a url search param, where everything is a string:
2296
+ * `?level=3,4` becomes `[3, 4]` instead of `["3", "4"]`. Without it items stay strings.
2261
2297
  * @param {number} [options.step] - For number type: step size for precision. Values will be rounded to nearest multiple of step.
2262
2298
  * @param {Array} [options.oneOf] - Array of valid values for validation. Signal will be marked invalid if value is not in this array
2263
2299
  * @param {boolean} [options.weak=false] - The param qualifies one visit, not the screen: it is written into a
@@ -5321,7 +5357,13 @@ const buildQueryString = (params) => {
5321
5357
 
5322
5358
  // Handle array values - join with commas
5323
5359
  if (Array.isArray(value)) {
5324
- if (value.length === 0) ; else {
5360
+ if (value.length === 0) {
5361
+ // Empty array - written as "key=", the form extractSearchParams reads
5362
+ // back as []. Omitting the param entirely would mean "absent" which
5363
+ // resolves to the default value, making "nothing selected"
5364
+ // inexpressible for a signal whose default is non-empty.
5365
+ searchParamPairs.push(`${encodedKey}=`);
5366
+ } else {
5325
5367
  const encodedValue = value
5326
5368
  .map((item) => encodeURIComponent(String(item)))
5327
5369
  .join(",");
@@ -5348,6 +5390,26 @@ const buildQueryString = (params) => {
5348
5390
  return searchParamPairs.join("&");
5349
5391
  };
5350
5392
 
5393
+ /**
5394
+ * Cast an array item read from the URL into the item type declared on the
5395
+ * signal (`stateSignal([], { type: "array", itemType: "number" })`).
5396
+ *
5397
+ * Without it every item comes back as a string and the value no longer equals
5398
+ * what was assigned, so the URL→signal sync overwrites the signal with strings.
5399
+ * Declared explicitly rather than guessed, so a "42" string item stays a string
5400
+ * unless the signal says otherwise.
5401
+ */
5402
+ const castStringToItemType = (item, itemType) => {
5403
+ if (itemType === "number" || itemType === "float") {
5404
+ const numberValue = Number(item);
5405
+ return isNaN(numberValue) ? item : numberValue;
5406
+ }
5407
+ if (itemType === "boolean") {
5408
+ return item === "true" || item === "1" || item === "";
5409
+ }
5410
+ return item;
5411
+ };
5412
+
5351
5413
  /**
5352
5414
  * Extract search parameters from URL
5353
5415
  */
@@ -5385,6 +5447,7 @@ const extractSearchParams = (urlObj, queryConnectionMap) => {
5385
5447
 
5386
5448
  const connection = queryConnectionMap.get(key);
5387
5449
  const signalType = connection ? connection.type : null;
5450
+ const itemType = connection ? connection.itemType : null;
5388
5451
 
5389
5452
  // Cast value based on signal type
5390
5453
  if (signalType === "array") {
@@ -5399,7 +5462,8 @@ const extractSearchParams = (urlObj, queryConnectionMap) => {
5399
5462
  params[key] = rawValue
5400
5463
  .split(",")
5401
5464
  .map((item) => decodeURIComponent(item))
5402
- .filter((item) => item.trim() !== "");
5465
+ .filter((item) => item.trim() !== "")
5466
+ .map((item) => castStringToItemType(item, itemType));
5403
5467
  }
5404
5468
  } else if (signalType === "number" || signalType === "float") {
5405
5469
  const decodedValue = decodeURIComponent(rawValue);
@@ -7010,7 +7074,7 @@ const getUIStateFromElement = (el, { own } = {}) => {
7010
7074
  */
7011
7075
  const asControlHostValue = (
7012
7076
  jsValue,
7013
- { controlType, type, inputMode },
7077
+ { controlType, type, inputMode, pad },
7014
7078
  ) => {
7015
7079
  if (controlType === "select") {
7016
7080
  // A select holds one of its options, always a string; holding nothing is
@@ -7027,7 +7091,7 @@ const asControlHostValue = (
7027
7091
  inputMode === "numeric" ||
7028
7092
  inputMode === "decimal"
7029
7093
  ) {
7030
- return asNumberString(jsValue);
7094
+ return asNumberString(jsValue, pad);
7031
7095
  }
7032
7096
  if (type === "color") {
7033
7097
  return asColorString(jsValue);
@@ -7051,11 +7115,24 @@ const asDatetimeLocalString = (dateTimeString) => {
7051
7115
  const seconds = String(date.getSeconds()).padStart(2, "0");
7052
7116
  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
7053
7117
  };
7054
- const asNumberString = (jsValue) => {
7118
+ // `pad` is how many digits the number is WRITTEN on — an hour is held as 7 and
7119
+ // shown as "07". Held and shown are two things here, the way they are for a
7120
+ // datetime-local above: what the field says is derived from what the control
7121
+ // holds, and reading it back (readNumberFromInput) gives the number again.
7122
+ const asNumberString = (jsValue, pad) => {
7055
7123
  if (jsValue === undefined) {
7056
7124
  return "";
7057
7125
  }
7058
- return jsValue;
7126
+ if (!pad || jsValue === "" || jsValue === null) {
7127
+ return jsValue;
7128
+ }
7129
+ const number = Number(jsValue);
7130
+ if (Number.isNaN(number)) {
7131
+ return jsValue;
7132
+ }
7133
+ const negative = number < 0;
7134
+ const digits = String(negative ? -number : number).padStart(Number(pad), "0");
7135
+ return negative ? `-${digits}` : digits;
7059
7136
  };
7060
7137
  // Browser requires a non-empty value for <input type="color">.
7061
7138
  // When our logical value is empty we give it #000000 so it doesn't choke.
@@ -7207,11 +7284,15 @@ const getRadioSiblings = (radioUIStateController) => {
7207
7284
  return siblings;
7208
7285
  };
7209
7286
 
7210
- const toDomValue = (jsValue, { controlType, id, type, inputMode }) => {
7287
+ const toDomValue = (
7288
+ jsValue,
7289
+ { controlType, id, type, inputMode, pad },
7290
+ ) => {
7211
7291
  const domValue = asControlHostValue(jsValue, {
7212
7292
  controlType,
7213
7293
  type,
7214
7294
  inputMode,
7295
+ pad,
7215
7296
  });
7216
7297
  if (isSerializableAsDomValue(domValue)) {
7217
7298
  return domValue;
@@ -12243,33 +12324,67 @@ const swipeTypeOf = (axis, pulled) => {
12243
12324
  };
12244
12325
 
12245
12326
  /**
12246
- * `move`, `reorder`, `toss` — one grab, and what letting go of it means.
12327
+ * `move`, `reorder`, `land`, `toss` — one grab, and what letting go of it means.
12247
12328
  *
12248
- * All three are the same gesture: the element is picked up and carried. What
12329
+ * All four are the same gesture: the element is picked up and carried. What
12249
12330
  * differs is the answer at the release, so one detector reads them all — it is one
12250
12331
  * press, and something has to arbitrate it.
12251
12332
  *
12252
12333
  * interactions={{ reorder: moveBefore, toss: remove }}
12334
+ * interactions={{ land: swapPlaces }}
12253
12335
  * interactions={{ move: remember }}
12254
12336
  *
12255
- * `reorder` and `toss` combine: a task dragged onto another changes places, the
12256
- * same task thrown far and fast is gotten rid of. `move` does not combine with
12257
- * `reorder` — an element either goes where it is put or takes a place in a list,
12258
- * and the two answers cannot both be true of one release.
12337
+ * `toss` combines with `reorder` and with `land`: a task dragged onto another
12338
+ * changes places, the same task thrown far and fast is gotten rid of. The three
12339
+ * others do not combine with each other — an element either goes where it is put,
12340
+ * takes a place in a list, or comes down on a place, and no two of those answers
12341
+ * can both be true of one release.
12259
12342
  *
12260
- * `move` carries the element ITSELF and leaves it where it was put; the other two
12343
+ * `move` carries the element ITSELF and leaves it where it was put; the others
12261
12344
  * carry a copy and put the original back. That is the same difference said in
12262
12345
  * layout terms: something moved has a new place of its own, something reordered
12263
12346
  * had its place taken by the list.
12264
12347
  *
12348
+ * `reorder` VS `land`: both come down on an item, and what separates them is what
12349
+ * a place IS. A row of a list is a place BETWEEN two others — free by construction,
12350
+ * so the answer is an insertion, and putting a row back where it already was is a
12351
+ * no-op. A place of a board is a place of its own, which may already be taken — so
12352
+ * nothing is inserted, nothing is a no-op, and the answer is simply "this one came
12353
+ * down on that one". What that means is the application's: take the place, swap the
12354
+ * two, refuse.
12355
+ *
12356
+ * interactions={{ land: (event) => {
12357
+ * const { fromId, toId, syncCloneWithDropTarget } = event.detail;
12358
+ * …
12359
+ * }}}
12360
+ *
12361
+ * `toId` is an element and never null: a copy over nothing is a release that meant
12362
+ * nothing, and the interaction does not happen at all.
12363
+ *
12364
+ * `syncCloneWithDropTarget` takes an element here, which `reorder` has no use for:
12365
+ * a place of a board can be larger than what stands on it (a quarter of a court, a
12366
+ * square holding a smaller piece), and the copy has to come down where the piece
12367
+ * will be rather than filling the place. Left alone, it lands on the place itself.
12368
+ *
12369
+ * WHICH ELEMENTS ARE PLACES: those marked `data-droppable`, and only those.
12370
+ * Declaring `land` says an element can be CARRIED, which on a board is a different
12371
+ * thing from being somewhere one can be put: a zone receives without ever being
12372
+ * carried, a piece is carried without ever receiving, and both at once is a third
12373
+ * case (dropped on a piece, the two swap). A list has no such distinction — every
12374
+ * row is both, which is why `reorder` needs no marker in the markup.
12375
+ *
12376
+ * The set of places is looked for inside the carried element's PARENT, so a place
12377
+ * and a piece are siblings: a piece nested inside its place would see only that
12378
+ * one, and have nowhere else to go.
12379
+ *
12265
12380
  * Nothing of the gesture is decided here. `startDragTo` owns all of it — the
12266
12381
  * copy carried above the page while the original keeps its place in the layout, the
12267
12382
  * drop hint, the drop targets found by intersection, the no-op drops filtered out,
12268
12383
  * the flight of a thrown copy and its return when the answer refuses. This says
12269
12384
  * which elements are the items, how they are named, and what a given release means.
12270
12385
  *
12271
- * WHICH ELEMENTS. Every element declaring `reorder` marks itself, so the set of
12272
- * items IS the set of elements that declared it — no selector to pass, nothing to
12386
+ * WHICH ELEMENTS, for `reorder`. Every element declaring it marks itself, so the
12387
+ * set of items IS the set of elements that declared it — no selector to pass, nothing to
12273
12388
  * keep in sync with the markup, and an item that must not move simply does not
12274
12389
  * declare it. An element that only declares `toss` marks nothing: it is not a place
12275
12390
  * anything lands.
@@ -12308,7 +12423,7 @@ const swipeTypeOf = (axis, pulled) => {
12308
12423
  *
12309
12424
  * interactions={{ toss: remove, grab: () => navigator.vibrate?.(10) }}
12310
12425
  *
12311
- * The three above all answer the RELEASE, and between the press and the release
12426
+ * The four above all answer the RELEASE, and between the press and the release
12312
12427
  * there is one instant that counts for the hand making the gesture: the one where
12313
12428
  * the object stops being pressed and starts being held. `grab` is that instant,
12314
12429
  * and it is the same one whichever way the drag was entered — a finger held still,
@@ -12322,7 +12437,7 @@ const swipeTypeOf = (axis, pulled) => {
12322
12437
  *
12323
12438
  * It is told, not asked: `grab` reports, so what it returns is not waited on and
12324
12439
  * preventing its event does not call the gesture off. And it is not an interaction
12325
- * on its own — declared without one of the three above there is no gesture for it
12440
+ * on its own — declared without one of the four above there is no gesture for it
12326
12441
  * to be the beginning of.
12327
12442
  *
12328
12443
  * A `longpress` needs nothing of this: it already happens at the moment the hold
@@ -12337,12 +12452,19 @@ const swipeTypeOf = (axis, pulled) => {
12337
12452
 
12338
12453
  const MOVE = "move";
12339
12454
  const REORDER = "reorder";
12455
+ // "drop" is taken: it is the name of the platform's own drag-and-drop event, and
12456
+ // an interaction is dispatched as an event of its own name — so anything listening
12457
+ // for a file being dropped on the page would get this one and read it as such.
12458
+ const LAND = "land";
12340
12459
  const TOSS = "toss";
12341
12460
  // The moment the press stops being a press and becomes a hold on the object.
12342
12461
  const GRAB = "grab";
12343
12462
 
12344
12463
  // What makes an element a place something can land, written by the detector itself.
12345
12464
  const REORDERABLE_ATTRIBUTE = "data-reorderable";
12465
+ // The same for `land`, except the markup is what writes it: a place of a board is
12466
+ // not the same thing as a piece of it (see the top of this file).
12467
+ const DROPPABLE_ATTRIBUTE = "data-droppable";
12346
12468
  // Which axes the drag walks: "x", "y" or "xy". Its default is not the same for
12347
12469
  // every outcome — a list runs one way, and something being put somewhere goes
12348
12470
  // wherever it is put.
@@ -12356,12 +12478,17 @@ const TOSS_SPEED_ATTRIBUTE = "data-toss-speed";
12356
12478
  defineInteractionDetector({
12357
12479
  name: "drag",
12358
12480
  claims: (type) =>
12359
- type === MOVE || type === REORDER || type === TOSS || type === GRAB,
12481
+ type === MOVE ||
12482
+ type === REORDER ||
12483
+ type === LAND ||
12484
+ type === TOSS ||
12485
+ type === GRAB,
12360
12486
  setup: (element, trigger, { types, readConfig }) => {
12361
12487
  const canMove = types.includes(MOVE);
12362
12488
  const canReorder = types.includes(REORDER);
12489
+ const canLand = types.includes(LAND);
12363
12490
  const canToss = types.includes(TOSS);
12364
- if (!canMove && !canReorder && !canToss) {
12491
+ if (!canMove && !canReorder && !canLand && !canToss) {
12365
12492
  return undefined;
12366
12493
  }
12367
12494
  const tellsWhenGrabbed = types.includes(GRAB);
@@ -12371,9 +12498,9 @@ defineInteractionDetector({
12371
12498
  const axes =
12372
12499
  axisHolder?.getAttribute(AXIS_ATTRIBUTE) ||
12373
12500
  // A list runs one way, and reordering walks it. Anything else goes wherever
12374
- // the hand takes it: a thing put somewhere has two axes to be put along, and
12375
- // a throw goes where it was thrown.
12376
- (canReorder && !canToss ? "y" : "xy");
12501
+ // the hand takes it: a board has places all around, a thing put somewhere has
12502
+ // two axes to be put along, and a throw goes where it was thrown.
12503
+ (canReorder && !canLand && !canToss ? "y" : "xy");
12377
12504
 
12378
12505
  if (canReorder) {
12379
12506
  element.setAttribute(REORDERABLE_ATTRIBUTE, "");
@@ -12394,7 +12521,11 @@ defineInteractionDetector({
12394
12521
  startDragTo(pointerDownEvent, effects, {
12395
12522
  draggedElement: element,
12396
12523
  // Nothing to land on when nothing reorders.
12397
- itemSelector: canReorder ? `[${REORDERABLE_ATTRIBUTE}]` : undefined,
12524
+ itemSelector: canLand
12525
+ ? `[${DROPPABLE_ATTRIBUTE}]`
12526
+ : canReorder
12527
+ ? `[${REORDERABLE_ATTRIBUTE}]`
12528
+ : undefined,
12398
12529
  getItemId: (itemElement) => itemElement.id,
12399
12530
  direction: { x: axes.includes("x"), y: axes.includes("y") },
12400
12531
  // Where it may go, said in the DOM. A thing that is put somewhere stays
@@ -12434,6 +12565,12 @@ defineInteractionDetector({
12434
12565
  toId,
12435
12566
  syncCloneWithDropTarget,
12436
12567
  }),
12568
+ onLand: (fromId, toId, syncCloneWithDropTarget) =>
12569
+ trigger(LAND, pointerDownEvent, {
12570
+ fromId,
12571
+ toId,
12572
+ syncCloneWithDropTarget,
12573
+ }),
12437
12574
  onToss: ({ gestureInfo }) =>
12438
12575
  trigger(TOSS, pointerDownEvent, {
12439
12576
  id: element.id,
@@ -14716,6 +14853,62 @@ const SINGLE_SPACE_CONSTRAINT = {
14716
14853
  };
14717
14854
  CONSTRAINT_ATTRIBUTE_SET.add("data-single-space");
14718
14855
 
14856
+ // A time that must not land before another one: the field says which one it
14857
+ // comes after (data-time-after, the id of the control holding it) and how much
14858
+ // room there must be between the two at least (data-time-min-duration, in
14859
+ // minutes — zero by default, so a span of no length is a span all the same).
14860
+ // Carried by the LATER of the two: it is the one that would have to move, so it
14861
+ // is the one the answer is about.
14862
+ const TIME_RANGE_CONSTRAINT = {
14863
+ name: "time_after",
14864
+ messageAttribute: "data-time-after-message",
14865
+ check: (field) => {
14866
+ const after = field.controlHostProps["data-time-after"];
14867
+ if (after === undefined) {
14868
+ return null;
14869
+ }
14870
+ const otherController = getUIStateControllerById(after);
14871
+ if (!otherController) {
14872
+ console.warn(`Time after constraint: no control with id "${after}"`);
14873
+ return null;
14874
+ }
14875
+ const timeBefore = minutesFromTime(otherController.uiState);
14876
+ const timeAfter = minutesFromTime(field.uiState);
14877
+ if (timeBefore === null || timeAfter === null) {
14878
+ return null;
14879
+ }
14880
+ const minDuration = Number(
14881
+ field.controlHostProps["data-time-min-duration"] ?? 0,
14882
+ );
14883
+ const duration = timeAfter - timeBefore;
14884
+ if (duration >= minDuration) {
14885
+ return null;
14886
+ }
14887
+ if (minDuration > 0) {
14888
+ return naviI18n("constraint.time_after.min_duration").replace(
14889
+ "[duration]",
14890
+ String(minDuration),
14891
+ );
14892
+ }
14893
+ return naviI18n("constraint.time_after.default");
14894
+ },
14895
+ };
14896
+ CONSTRAINT_ATTRIBUTE_SET.add("data-time-after");
14897
+ CONSTRAINT_ATTRIBUTE_SET.add("data-time-min-duration");
14898
+
14899
+ // "HH:MM" as a number of minutes, which is what two times are compared and
14900
+ // subtracted as. Anything else is a time nobody has finished writing.
14901
+ const minutesFromTime = (time) => {
14902
+ if (typeof time !== "string") {
14903
+ return null;
14904
+ }
14905
+ const match = /^(\d{1,2}):(\d{1,2})$/.exec(time);
14906
+ if (!match) {
14907
+ return null;
14908
+ }
14909
+ return Number(match[1]) * 60 + Number(match[2]);
14910
+ };
14911
+
14719
14912
  /**
14720
14913
  * Custom form validation implementation
14721
14914
  *
@@ -14785,6 +14978,7 @@ const NAVI_CONSTRAINT_SET = new Set([
14785
14978
  MIN_LOWER_LETTER_CONSTRAINT,
14786
14979
  SAME_AS_CONSTRAINT,
14787
14980
  ONE_OF_CONSTRAINT,
14981
+ TIME_RANGE_CONSTRAINT,
14788
14982
  ]);
14789
14983
  const DEFAULT_CONSTRAINT_SET = new Set([
14790
14984
  ...STANDARD_CONSTRAINT_SET,
@@ -17048,6 +17242,7 @@ const resolveSpacingSize = (size, element, property = "padding") => {
17048
17242
  };
17049
17243
 
17050
17244
  const COLOR_KEYWORD_MAP = {
17245
+ primary: "var(--navi-color-primary)",
17051
17246
  secondary: "var(--navi-color-secondary)",
17052
17247
  emphasis: "var(--navi-color-emphasis)",
17053
17248
  discrete: "var(--navi-color-discrete)",
@@ -22144,6 +22339,7 @@ const CONTROL_ATTRIBUTE_SET = new Set([
22144
22339
 
22145
22340
  // "ui-action-target",
22146
22341
  "navi-input-type",
22342
+ "navi-value-pad",
22147
22343
  "navi-control-proxy-for",
22148
22344
  "navi-command-proxy-for",
22149
22345
  "navi-command-target",
@@ -23408,6 +23604,12 @@ const useUIGroupStateController = (
23408
23604
  const debugUIGroup = useDebugUIState();
23409
23605
  const debugFocus = useDebugFocus();
23410
23606
 
23607
+ // What the group is worth is one key per child (or one item per child) only
23608
+ // as long as nobody said otherwise: a group with its own aggregate is worth
23609
+ // whatever IT says — a "HH:MM", an ISO duration — and the shape checks in
23610
+ // setUIState below are about the default shape, not about that one.
23611
+ const stateShapeIsTheDefaultOne =
23612
+ !aggregateChildStates && !distributeChildUIState;
23411
23613
  const defaults = GROUP_DEFAULTS[controlType] ?? GROUP_DEFAULTS[stateType];
23412
23614
  const resolvedChildControlFilter =
23413
23615
  childControlFilter ?? defaults?.childControlFilter ?? null;
@@ -23581,6 +23783,7 @@ const useUIGroupStateController = (
23581
23783
  setUIState: (newUIState, e) => {
23582
23784
  if (
23583
23785
  stateType === "object" &&
23786
+ stateShapeIsTheDefaultOne &&
23584
23787
  (newUIState === null || typeof newUIState !== "object")
23585
23788
  ) {
23586
23789
  console.warn(
@@ -23589,7 +23792,11 @@ const useUIGroupStateController = (
23589
23792
  );
23590
23793
  return;
23591
23794
  }
23592
- if (stateType === "array" && !Array.isArray(newUIState)) {
23795
+ if (
23796
+ stateType === "array" &&
23797
+ stateShapeIsTheDefaultOne &&
23798
+ !Array.isArray(newUIState)
23799
+ ) {
23593
23800
  console.warn(
23594
23801
  `[${controlType}] setUIState received a non-array value: ${JSON.stringify(newUIState)} (expected an array). Ignoring.`,
23595
23802
  newUIState,
@@ -24321,7 +24528,10 @@ const useControlProps = (props, {
24321
24528
  controlType,
24322
24529
  id: props.id,
24323
24530
  type: props.type,
24324
- inputMode: props.inputMode
24531
+ inputMode: props.inputMode,
24532
+ // How the value is WRITTEN where it is held one way and shown another —
24533
+ // a number on two digits ("07" for 7). See asControlHostValue.
24534
+ pad: props["navi-value-pad"]
24325
24535
  });
24326
24536
  return {
24327
24537
  value: domValue
@@ -24332,6 +24542,13 @@ const useControlProps = (props, {
24332
24542
  if (!el) {
24333
24543
  return;
24334
24544
  }
24545
+ // The field one is typing in is where the value comes FROM, and what is in
24546
+ // it already says this: writing it back in the form it is shown in ("07"
24547
+ // for a 7 just typed) would move the caret and stop the person mid-number.
24548
+ // What is shown is derived again when the field is left (see below).
24549
+ if (document.activeElement === el && readControlValue(el) === newUIState) {
24550
+ return;
24551
+ }
24335
24552
  const domProps = toDomProps(newUIState);
24336
24553
  Object.assign(el, domProps);
24337
24554
  debugUIState(e, `syncDomState: updated to ${getElementSignature(el)}`, domProps);
@@ -24919,9 +25136,32 @@ const useControlProps = (props, {
24919
25136
  onPaste,
24920
25137
  onInput
24921
25138
  });
25139
+ // A value written in a form of its own ("07" for the number 7) is derived
25140
+ // again when the field is left: while it is being typed into, what is in
25141
+ // the field is what the person is writing and nothing rewrites it (see
25142
+ // syncDomState). Only for such a control — for every other one the field
25143
+ // already shows exactly what is held, and there is nothing to derive.
25144
+ if (props["navi-value-pad"]) {
25145
+ const onBlurFromProps = controlHostProps.onBlur;
25146
+ controlHostProps.onBlur = e => {
25147
+ onBlurFromProps?.(e);
25148
+ // Read from the field: what was just typed is in there, whatever the
25149
+ // control has had time to settle on.
25150
+ const el = e.currentTarget;
25151
+ syncDomState(readControlValue(el), e);
25152
+ };
25153
+ }
24922
25154
  }
24923
25155
  const uiState = uiStateController.uiStateSignal.peek();
24924
25156
  const domProps = toDomProps(uiState);
25157
+ {
25158
+ // Same as syncDomState: a field being typed into keeps its own text, so a
25159
+ // render happening mid-number does not put the caret back at the end.
25160
+ const el = props.ref.current;
25161
+ if (el && document.activeElement === el && readControlValue(el) === uiState) {
25162
+ domProps.value = el.value;
25163
+ }
25164
+ }
24925
25165
  Object.assign(controlHostProps, domProps);
24926
25166
  return [controlRootProps, controlHostProps, {
24927
25167
  uiStateController
@@ -25061,17 +25301,26 @@ const createControlInfo = (props, {
25061
25301
  };
25062
25302
  // color, radio, image, file etc do not support readonly
25063
25303
  const INPUT_TYPE_SUPPORTING_READONLY_SET = new Set(["text", "date", "datetime-local", "email", "month", "number", "password", "search", "tel", "time", "url", "week"]);
25064
- const useReadOnlyUncontrolled = (props, controlInfo) => {
25065
- if (!controlInfo.hasStateProp) {
25066
- return false;
25067
- }
25304
+ // Who, if anyone, is listening to what this control is worth: a handler of its
25305
+ // own, a bound signal, a command — or the form/group around it, which is the
25306
+ // one that will send the value and hand a new one back. Held apart from the
25307
+ // warning below so a group can ask the same question a single control does: a
25308
+ // control with a `value` and nobody listening cannot be changed by hand, and
25309
+ // that is true whatever the control is.
25310
+ const useIsControlListenedTo = props => {
25068
25311
  const isProxy = Boolean(props["navi-control-proxy-for"]);
25069
25312
  const formContext = useContext(FormContext);
25070
25313
  const parentUIStateController = useContext(ParentUIStateControllerContext);
25071
- const controlled = props.signal ||
25314
+ return Boolean(props.signal ||
25072
25315
  // a bound signal is written back on uiAction → interactive
25073
- props.uiAction || props.action || formContext || parentUIStateController || isProxy || props.command;
25074
- if (controlled) {
25316
+ props.uiAction || props.action || formContext || parentUIStateController || isProxy || props.command);
25317
+ };
25318
+ const useReadOnlyUncontrolled = (props, controlInfo) => {
25319
+ const listenedTo = useIsControlListenedTo(props);
25320
+ if (!controlInfo.hasStateProp) {
25321
+ return false;
25322
+ }
25323
+ if (listenedTo) {
25075
25324
  return false;
25076
25325
  }
25077
25326
  if (
@@ -25117,9 +25366,12 @@ const useControlgroupProps = (props, {
25117
25366
  cascadeValidationToChildren
25118
25367
  });
25119
25368
  const [boundAction] = useActionBoundToOneParam(action, uiGroupStateController.uiStateSignal);
25120
- // Mirror single-input behaviour: a controlled value with no handler makes the
25121
- // group read-only so children don't appear interactive when they can't change.
25122
- const implicitReadOnly = uiGroupStateController.hasValueProp && !action && !props.uiAction;
25369
+ // Mirror single-input behaviour: a controlled value with nobody listening
25370
+ // makes the group read-only so children don't appear interactive when they
25371
+ // can't change. A form or a group around it IS someone listening — that is
25372
+ // what will send the value and hand a new one back.
25373
+ const listenedTo = useIsControlListenedTo(props);
25374
+ const implicitReadOnly = uiGroupStateController.hasValueProp && !listenedTo;
25123
25375
  if (implicitReadOnly && !props.readOnly) {
25124
25376
  props.readOnly = true;
25125
25377
  }
@@ -44930,20 +45182,16 @@ const InputModeNumericOrDecimal = props => {
44930
45182
  return;
44931
45183
  }
44932
45184
  const input = e.currentTarget;
44933
- let maxLength;
44934
- const maxLengthProp = input.maxLength;
44935
- if (maxLengthProp === -1) {
45185
+ let maxLength = input.maxLength;
45186
+ if (maxLength === -1) {
44936
45187
  const naviMaxLengthAttr = input.getAttribute("navi-max-length");
44937
- if (naviMaxLengthAttr === null) {
44938
- // no max length
44939
- return;
44940
- }
44941
- maxLength = Number(naviMaxLengthAttr);
45188
+ maxLength = naviMaxLengthAttr === null ? undefined : Number(naviMaxLengthAttr);
44942
45189
  }
44943
- if (input.value.length < maxLength) {
45190
+ const caretAtEnd = input.selectionStart === input.value.length;
45191
+ if (!caretAtEnd) {
44944
45192
  return;
44945
45193
  }
44946
- if (input.selectionStart !== maxLength) {
45194
+ if (!isFull(input, maxLength)) {
44947
45195
  return;
44948
45196
  }
44949
45197
  // Field is full and caret is at the end: notify listeners then
@@ -44952,7 +45200,11 @@ const InputModeNumericOrDecimal = props => {
44952
45200
  const allowed = dispatchPublicCustomEvent(input, "navi_input_full", {
44953
45201
  event: e
44954
45202
  });
44955
- if (allowed) {
45203
+ // Only for a value being typed: selecting focuses the field, and a
45204
+ // value set from elsewhere (a chevron, a form) has nobody typing — on a
45205
+ // phone that focus raises the on-screen keyboard over the page. Same
45206
+ // question useInputGroup asks before moving along to the next field.
45207
+ if (allowed && e.isTrusted) {
44956
45208
  input.select();
44957
45209
  }
44958
45210
  },
@@ -44969,6 +45221,32 @@ const InputModeNumericOrDecimal = props => {
44969
45221
  });
44970
45222
  };
44971
45223
 
45224
+ // A field is full when there is no room for another digit — and room is not
45225
+ // only a number of characters: an hour of two digits at most is also full at
45226
+ // "7", because 7 followed by anything is past the 23 it accepts. Both are the
45227
+ // same question ("can one more digit still land here?"), so both move the
45228
+ // person filling it in along to the next field.
45229
+ const isFull = (input, maxLength) => {
45230
+ const value = input.value;
45231
+ if (value === "") {
45232
+ return false;
45233
+ }
45234
+ if (maxLength !== undefined && value.length >= maxLength) {
45235
+ return true;
45236
+ }
45237
+ const max = input.max === "" ? undefined : Number(input.max);
45238
+ if (max === undefined || Number.isNaN(max)) {
45239
+ return false;
45240
+ }
45241
+ // The smallest number one more digit could make: a zero appended to what is
45242
+ // already there.
45243
+ const withOneMoreDigit = Number(`${value}0`);
45244
+ if (Number.isNaN(withOneMoreDigit)) {
45245
+ return false;
45246
+ }
45247
+ return withOneMoreDigit > max;
45248
+ };
45249
+
44972
45250
  // hum il manque le faire de request interaction ici
44973
45251
  const performArrowUpDown = e => {
44974
45252
  const input = e.currentTarget;
@@ -47536,12 +47814,6 @@ const css$A = /* css */`
47536
47814
  &[data-travel-by-drag="xy"] {
47537
47815
  touch-action: none;
47538
47816
  }
47539
- /* A drag is not a selection: without this a mouse pulling a slide paints
47540
- the text it passes over blue. */
47541
- &[data-slide-dragging] {
47542
- user-select: none;
47543
- }
47544
-
47545
47817
  /* Outside the box, which is where an outline is drawn by default: nothing
47546
47818
  inside can paint over it (the slides are all within), and this box's own
47547
47819
  overflow does not clip it either — an element's outline is not its own
@@ -49134,7 +49406,6 @@ const SlideContainer = ({
49134
49406
  };
49135
49407
  drag.progress = slack / size;
49136
49408
  stageDrag(drag);
49137
- containerRef.current.toggleAttribute("data-slide-dragging", true);
49138
49409
  // From here the box is busy, whichever input asked: a wheel gesture and
49139
49410
  // a press must not both be moving the same track.
49140
49411
  dragRef.current = drag;
@@ -49165,7 +49436,6 @@ const SlideContainer = ({
49165
49436
  event
49166
49437
  }) => {
49167
49438
  dragRef.current = null;
49168
- containerRef.current?.removeAttribute("data-slide-dragging");
49169
49439
  if (!travels) {
49170
49440
  returnToRest(drag);
49171
49441
  return;
@@ -50366,6 +50636,228 @@ const toDate = (value, parseString) => {
50366
50636
  return null;
50367
50637
  };
50368
50638
 
50639
+ /**
50640
+ * Wraps multiple inputs together and handles keyboard navigation and paste
50641
+ * distribution between them.
50642
+ *
50643
+ * Keyboard navigation:
50644
+ * ArrowRight at the end of an input moves focus to the next input.
50645
+ * ArrowLeft at the start of an input moves focus to the previous input.
50646
+ * navi_input_full (emitted when an input reaches maxLength) also moves forward.
50647
+ *
50648
+ * Paste distribution:
50649
+ * When an input has a data-separator attribute, pasting a string that
50650
+ * contains that separator (e.g. "27/04/1990" into a day input with
50651
+ * data-separator="/") splits the text on each separator and fills the
50652
+ * corresponding sub-inputs in order.
50653
+ */
50654
+ const useInputGroup = (ref) => {
50655
+ const debugFocus = useDebugFocus();
50656
+
50657
+ useEffect(() => {
50658
+ const el = ref.current;
50659
+ if (!el) {
50660
+ return () => {};
50661
+ }
50662
+
50663
+ const getInputs = () =>
50664
+ Array.from(el.querySelectorAll(".navi_control_input"));
50665
+
50666
+ const focusInput = (input) => {
50667
+ input.focus();
50668
+ input.select();
50669
+ };
50670
+
50671
+ const handleKeyDown = (e) => {
50672
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
50673
+ return;
50674
+ }
50675
+ const active = document.activeElement;
50676
+ if (!isTextInputElement(active) || !el.contains(active)) {
50677
+ return;
50678
+ }
50679
+ if (e.key === "ArrowRight") {
50680
+ const allSelected =
50681
+ active.selectionStart === 0 &&
50682
+ active.selectionEnd === active.value.length;
50683
+ const atEnd =
50684
+ allSelected ||
50685
+ (active.selectionStart === active.value.length &&
50686
+ active.selectionEnd === active.value.length);
50687
+ if (!atEnd) {
50688
+ return;
50689
+ }
50690
+ const inputs = getInputs();
50691
+ const idx = inputs.indexOf(active);
50692
+ if (idx === -1) {
50693
+ debugFocus(
50694
+ e,
50695
+ "InputGroup ArrowRight on non group input → do nothing",
50696
+ );
50697
+ return;
50698
+ }
50699
+ if (idx === inputs.length - 1) {
50700
+ debugFocus(
50701
+ e,
50702
+ "InputGroup ArrowRight at end of last input → do nothing",
50703
+ );
50704
+ return;
50705
+ }
50706
+
50707
+ debugFocus(
50708
+ e,
50709
+ "InputGroup ArrowRight at end of input[%d] → focus input[%d]",
50710
+ idx,
50711
+ idx + 1,
50712
+ );
50713
+ e.preventDefault();
50714
+ focusInput(inputs[idx + 1]);
50715
+ return;
50716
+ }
50717
+ const allSelected =
50718
+ active.selectionStart === 0 &&
50719
+ active.selectionEnd === active.value.length;
50720
+ const atStart =
50721
+ allSelected ||
50722
+ (active.selectionStart === 0 && active.selectionEnd === 0);
50723
+ if (!atStart) {
50724
+ return;
50725
+ }
50726
+ const inputs = getInputs();
50727
+ const idx = inputs.indexOf(active);
50728
+ if (idx === 0) {
50729
+ return;
50730
+ }
50731
+ debugFocus(
50732
+ e,
50733
+ "InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
50734
+ idx,
50735
+ idx - 1,
50736
+ );
50737
+ e.preventDefault();
50738
+ focusInput(inputs[idx - 1]);
50739
+ };
50740
+
50741
+ const handleNaviInputFull = (e) => {
50742
+ if (!e.detail.event?.isTrusted) {
50743
+ // Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
50744
+ return;
50745
+ }
50746
+ const input = e.detail.event.currentTarget;
50747
+ if (!el.contains(input)) {
50748
+ return;
50749
+ }
50750
+ const inputs = getInputs();
50751
+ const idx = inputs.indexOf(input);
50752
+ if (idx === -1) {
50753
+ return;
50754
+ }
50755
+ if (idx === inputs.length - 1) {
50756
+ return;
50757
+ }
50758
+ const nextInput = inputs[idx + 1];
50759
+ debugFocus(
50760
+ e,
50761
+ "InputGroup navi_input_full on input -> move to next input",
50762
+ input,
50763
+ nextInput,
50764
+ );
50765
+ e.preventDefault();
50766
+ focusInput(nextInput);
50767
+ };
50768
+
50769
+ // const handlePaste = (e) => {
50770
+ // const active = document.activeElement;
50771
+ // if (!isTextInputElement(active) || !el.contains(active)) {
50772
+ // return;
50773
+ // }
50774
+ // const inputs = getInputs();
50775
+ // const startIdx = inputs.indexOf(active);
50776
+ // if (startIdx === -1) {
50777
+ // return;
50778
+ // }
50779
+ // const pastedText = e.clipboardData?.getData("text") ?? "";
50780
+ // if (!pastedText) {
50781
+ // return;
50782
+ // }
50783
+ // // Only intercept when the pasted text contains at least one separator
50784
+ // // from the inputs starting at the focused position.
50785
+ // const remainingInputs = inputs.slice(startIdx);
50786
+ // const hasSeparatorMatch = remainingInputs.some(
50787
+ // (input) =>
50788
+ // input.dataset.separator &&
50789
+ // pastedText.includes(input.dataset.separator),
50790
+ // );
50791
+ // if (!hasSeparatorMatch) {
50792
+ // return;
50793
+ // }
50794
+ // e.preventDefault();
50795
+ // let remaining = pastedText;
50796
+ // let lastFilledIdx = startIdx;
50797
+ // for (let i = 0; i < remainingInputs.length; i++) {
50798
+ // const input = remainingInputs[i];
50799
+ // const separator = input.dataset.separator;
50800
+ // let part;
50801
+ // if (separator && remaining.includes(separator)) {
50802
+ // const sepIdx = remaining.indexOf(separator);
50803
+ // part = remaining.slice(0, sepIdx);
50804
+ // remaining = remaining.slice(sepIdx + separator.length);
50805
+ // } else {
50806
+ // part = remaining;
50807
+ // remaining = "";
50808
+ // }
50809
+ // requestSubPaste(input, part, e);
50810
+ // lastFilledIdx = startIdx + i;
50811
+ // if (remaining === "") {
50812
+ // break;
50813
+ // }
50814
+ // }
50815
+ // focusInput(inputs[lastFilledIdx]);
50816
+ // };
50817
+
50818
+ el.addEventListener("keydown", handleKeyDown, { capture: true });
50819
+ el.addEventListener("navi_input_full", handleNaviInputFull);
50820
+ // el.addEventListener("paste", handlePaste, { capture: true });
50821
+ return () => {
50822
+ el.removeEventListener("keydown", handleKeyDown, { capture: true });
50823
+ el.removeEventListener("navi_input_full", handleNaviInputFull);
50824
+ // el.removeEventListener("paste", handlePaste, { capture: true });
50825
+ };
50826
+ }, [debugFocus]);
50827
+ };
50828
+
50829
+ // const requestSubPaste = (input, value, event) => {
50830
+ // dispatchRequestInteraction(input, {
50831
+ // event,
50832
+ // name: "subpaste",
50833
+ // allowed: () => {
50834
+ // dispatchRequestSetUIState(input, value, { event });
50835
+ // },
50836
+ // });
50837
+ // };
50838
+
50839
+ const isTextInputElement = (el) => {
50840
+ if (!el) {
50841
+ return false;
50842
+ }
50843
+ if (el.tagName === "TEXTAREA") {
50844
+ return true;
50845
+ }
50846
+ if (el.tagName !== "INPUT") {
50847
+ return false;
50848
+ }
50849
+ const type = el.type || "text";
50850
+ return (
50851
+ type === "text" ||
50852
+ type === "search" ||
50853
+ type === "url" ||
50854
+ type === "tel" ||
50855
+ type === "email" ||
50856
+ type === "password" ||
50857
+ type === "number"
50858
+ );
50859
+ };
50860
+
50369
50861
  // When a component render a prop that can be anything (js value of preact element)
50370
50862
  // make sure it cannot throw during render by converting it to a string if it's not a valid preact element or a primitive value
50371
50863
  const renderSafe = (value) => {
@@ -53404,6 +53896,14 @@ const css$v = /* css */`
53404
53896
  text-transform: uppercase;
53405
53897
  letter-spacing: 0.05em;
53406
53898
  }
53899
+
53900
+ /* A group whose rows all failed the search keeps its height (its rows are
53901
+ still there, invisible) — the label must disappear with them, otherwise
53902
+ the list shows a title standing over nothing. Same aria-hidden + inert
53903
+ pair as the rows themselves. */
53904
+ &[aria-hidden="true"][inert] {
53905
+ opacity: 0;
53906
+ }
53407
53907
  }
53408
53908
  .navi_list_item_group_list {
53409
53909
  display: flex;
@@ -56523,6 +57023,14 @@ const ListItemGroup = ({
56523
57023
  }) => {
56524
57024
  const groupId = useId();
56525
57025
  const groupTracker = useItemTracker();
57026
+ const searchNoMatchMode = useContext(SearchNoMatchModeContext);
57027
+ const groupItemCount = groupTracker.countSignal.value;
57028
+ const groupNoMatchCount = groupTracker.noMatchCountSignal.value;
57029
+ // Every row of this group failed the search: the label has nothing left to
57030
+ // title. "remove" empties the group on its own (and hiddenWhileEmpty takes it
57031
+ // out of the flow), "muted" keeps the rows readable so the label stays useful
57032
+ // — only "invisible_and_inert" would leave a title floating over blank space.
57033
+ const labelHidden = searchNoMatchMode === "invisible_and_inert" && groupNoMatchCount > 0 && groupNoMatchCount === groupItemCount;
56526
57034
  const groupRef = useRef(null);
56527
57035
  const labelRef = useRef(null);
56528
57036
  useDisplayedLayoutEffect(labelRef, labelEl => {
@@ -56544,7 +57052,9 @@ const ListItemGroup = ({
56544
57052
  ref: labelRef,
56545
57053
  id: groupId,
56546
57054
  className: "navi_list_item_group_label",
56547
- role: "presentation"
57055
+ role: "presentation",
57056
+ "aria-hidden": labelHidden ? "true" : undefined,
57057
+ inert: labelHidden ? true : undefined
56548
57058
  // eslint-disable-next-line react/no-unknown-property
56549
57059
  ,
56550
57060
 
@@ -58200,6 +58710,15 @@ const css$q = /* css */`
58200
58710
  --picker-spin-padding-x-default: var(--navi-picker-padding-x-default);
58201
58711
  --picker-spin-padding-y-default: var(--navi-picker-padding-y-default);
58202
58712
  }
58713
+ /* Written in what it is written with, for a value one TYPES: a field is as
58714
+ wide as the digits in it and no wider, so the room around them has to
58715
+ come from here — and the two chevrons, which take the same, are then big
58716
+ enough to be aimed at with a finger. Said as a default, so a padding prop
58717
+ still wins. */
58718
+ .navi_picker_spin:has(> .navi_picker_spin_middle > .navi_input) {
58719
+ --picker-spin-padding-x-default: 0.6em;
58720
+ --picker-spin-padding-y-default: 0.25em;
58721
+ }
58203
58722
  }
58204
58723
 
58205
58724
  .navi_picker_spin {
@@ -58331,6 +58850,16 @@ const css$q = /* css */`
58331
58850
  min-width: 0;
58332
58851
  flex: 1 1 auto;
58333
58852
  }
58853
+ /* The same room around a value one TYPES as around one one picks (the
58854
+ [data-slide] rule below writes it there): without it the column is as
58855
+ narrow as the two digits in it, and a number pressed against both sides
58856
+ reads as a field too small for what it holds. Handed to the field as its
58857
+ own padding rather than kept by the middle: the field then IS the column —
58858
+ it fills it, and a click anywhere in it lands on the caret. */
58859
+ .navi_picker_spin_middle > .navi_input {
58860
+ --padding-right: var(--x-picker-spin-padding-right);
58861
+ --padding-left: var(--x-picker-spin-padding-left);
58862
+ }
58334
58863
  /* Where the padding lands: all four sides on the value, the two vertical
58335
58864
  ones on the chevrons below — the same number above and below is what makes
58336
58865
  the three one line rather than three boxes, while sideways it is the room
@@ -58408,8 +58937,19 @@ const css$q = /* css */`
58408
58937
  aspect-ratio: 1;
58409
58938
  justify-content: center;
58410
58939
  }
58940
+ /* Standing up, a chevron takes the whole width and whatever height is left
58941
+ over: a box taller than the three pieces in it (a height of its own, room
58942
+ asked for around the value) would otherwise leave a strip of nothing
58943
+ between the chevron and the border, and one pressing what looks like the
58944
+ bottom of the box would hit nothing. */
58411
58945
  .navi_picker_spin[data-vertical] > .navi_picker_spin_way_out {
58412
58946
  width: 100%;
58947
+ height: auto;
58948
+ min-height: calc(
58949
+ 1lh + var(--x-picker-spin-padding-top) +
58950
+ var(--x-picker-spin-padding-bottom)
58951
+ );
58952
+ flex: 1 0 auto;
58413
58953
  justify-content: center;
58414
58954
  }
58415
58955
  /* The corners of the box belong to what sits in them: a chevron in the corner
@@ -58441,6 +58981,92 @@ const css$q = /* css */`
58441
58981
  border-end-end-radius: inherit;
58442
58982
  border-end-start-radius: inherit;
58443
58983
  }
58984
+
58985
+ /* ── SpinGroup ─────────────────────────────────────────────────────────────
58986
+ Several spins read as one value: an hour is "7h30", not a 7 next to a 30.
58987
+ So the frame goes around the group, the spins inside give theirs up, and
58988
+ what sits between them (an "h", a ":") is inside the frame with them. */
58989
+ .navi_spin_group {
58990
+ /* What the loading outline is drawn around. */
58991
+ position: relative;
58992
+ display: inline-flex;
58993
+ align-items: center;
58994
+ font-size: var(--navi-control-font-size);
58995
+ font-family: var(--navi-control-font-family);
58996
+ border: var(--navi-control-border-width) solid
58997
+ var(--navi-control-border-color);
58998
+ border-radius: var(--navi-control-border-radius);
58999
+ outline-width: var(--navi-focus-outline-width);
59000
+ outline-color: var(--navi-focus-outline-color);
59001
+ outline-offset: 0px;
59002
+ -webkit-tap-highlight-color: var(--navi-control-tap-highlight-color);
59003
+ }
59004
+ /* A value one PICKS has nowhere of its own to wear a ring — its middle is a
59005
+ container that hands the ring over (data-focus-outline-delegate) — so the
59006
+ group wears it for that spin. A value one TYPES keeps its own, on the
59007
+ field: the spins in a group are edited one at a time, and the ring is what
59008
+ says which one the keyboard is in. */
59009
+ .navi_spin_group[data-focus-visible],
59010
+ .navi_spin_group:has([data-focus-outline-delegate][data-focus-visible]) {
59011
+ outline-style: solid;
59012
+ }
59013
+ .navi_spin_group .navi_picker_spin {
59014
+ border: none;
59015
+ border-radius: 0;
59016
+ }
59017
+ /* The corners of the group belong to the spins sitting in them, and through
59018
+ them to their chevrons, which are rounded by whatever their spin is. */
59019
+ .navi_spin_group > .navi_picker_spin:first-child {
59020
+ border-start-start-radius: inherit;
59021
+ border-end-start-radius: inherit;
59022
+ }
59023
+ .navi_spin_group > .navi_picker_spin:last-child {
59024
+ border-start-end-radius: inherit;
59025
+ border-end-end-radius: inherit;
59026
+ }
59027
+ .navi_spin_group .navi_picker_spin[data-focus-visible],
59028
+ .navi_spin_group
59029
+ .navi_picker_spin:has([data-focus-outline-delegate][data-focus-visible]),
59030
+ .navi_spin_group .navi_picker_spin:has(.navi_input[data-focus-visible]) {
59031
+ outline-style: none;
59032
+ }
59033
+ /* Fading the frame is the group's to do, since the frame is the group's. */
59034
+ .navi_spin_group[data-readonly],
59035
+ .navi_spin_group[data-loading] {
59036
+ border-color: color-mix(
59037
+ in srgb,
59038
+ var(--navi-control-border-color) 45%,
59039
+ transparent
59040
+ );
59041
+ }
59042
+ .navi_spin_group[data-disabled] {
59043
+ color: color-mix(in srgb, currentColor 40%, transparent);
59044
+ border-color: color-mix(
59045
+ in srgb,
59046
+ var(--navi-control-border-color) 30%,
59047
+ transparent
59048
+ );
59049
+ }
59050
+ /* As tall as the spins beside it, so what it says lands on their line. */
59051
+ .navi_spin_group_separator {
59052
+ display: flex;
59053
+ align-items: center;
59054
+ align-self: stretch;
59055
+ justify-content: center;
59056
+ color: inherit;
59057
+ white-space: nowrap;
59058
+ user-select: none;
59059
+ }
59060
+ /* The "h" fades with the values it sits between: it is one control, and a
59061
+ word left black beside two grey numbers reads as half a control out of
59062
+ service. Same mixes as the spins' own (see above). */
59063
+ .navi_spin_group[data-readonly] .navi_spin_group_separator,
59064
+ .navi_spin_group[data-loading] .navi_spin_group_separator {
59065
+ color: color-mix(in srgb, currentColor 60%, transparent);
59066
+ }
59067
+ .navi_spin_group[data-disabled] .navi_spin_group_separator {
59068
+ color: color-mix(in srgb, currentColor 40%, transparent);
59069
+ }
58444
59070
  `;
58445
59071
 
58446
59072
  /**
@@ -58533,6 +59159,7 @@ const Spin = ({
58533
59159
  readOnly,
58534
59160
  disabled,
58535
59161
  loading,
59162
+ size,
58536
59163
  maxLines,
58537
59164
  previousLabel,
58538
59165
  nextLabel,
@@ -58540,6 +59167,10 @@ const Spin = ({
58540
59167
  }) => {
58541
59168
  import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
58542
59169
  const id = useId();
59170
+ // What the group around it says, when there is one: how big the whole thing
59171
+ // is written is said once, on the group, and every spin in it follows.
59172
+ const group = useContext(SpinGroupContext);
59173
+ const sizeResolved = size ?? group?.size;
58543
59174
  const containerId = `${id}_values`;
58544
59175
  const controlId = `${id}_control`;
58545
59176
  // The control holds the value, and it is asked rather than shadowed:
@@ -58666,7 +59297,19 @@ const Spin = ({
58666
59297
  // it in this spin, so one can keep going with the keys where one was. Found
58667
59298
  // in the middle rather than by id — a field's id lands on the box around it,
58668
59299
  // and what takes the keyboard is the input inside.
59300
+ //
59301
+ // Not under a finger: focusing a field on a phone raises the on-screen
59302
+ // keyboard over half the page, and someone pressing a chevron is stepping
59303
+ // through values, not about to type one. With a mouse the opposite is true —
59304
+ // one presses once and then keeps going with the arrow keys — so the keyboard
59305
+ // is put back where it was. Which one it is comes from the pointer that
59306
+ // started the press (same convention as button_ui.jsx and
59307
+ // use_autoselect_read_only.js).
59308
+ const wayOutPointerTypeRef = useRef(null);
58669
59309
  const focusMiddle = () => {
59310
+ if (wayOutPointerTypeRef.current === "touch") {
59311
+ return;
59312
+ }
58670
59313
  const target = editable ? middleRef.current?.querySelector(".navi_control_input") : document.getElementById(containerId);
58671
59314
  target?.focus({
58672
59315
  preventScroll: true
@@ -58686,6 +59329,9 @@ const Spin = ({
58686
59329
  const isNext = atStart ? startIsNext : !startIsNext;
58687
59330
  return jsx(WayOut, {
58688
59331
  atStart: atStart,
59332
+ onPointerDown: e => {
59333
+ wayOutPointerTypeRef.current = e.pointerType;
59334
+ },
58689
59335
  unavailableMessage: wayOutMessage(atStart ? startAllowed : endAllowed, isNext ? "spin.nothing_after" : "spin.nothing_before"),
58690
59336
  label: isNext ? nextLabel ?? naviI18n("spin.next") : previousLabel ?? naviI18n("spin.previous"),
58691
59337
  onPress: e => {
@@ -58713,6 +59359,7 @@ const Spin = ({
58713
59359
  // asked for by hand (pseudoState) as well as held for real.
58714
59360
  ,
58715
59361
 
59362
+ size: sizeResolved,
58716
59363
  pseudoClasses: PICKER_SPIN_PSEUDO_CLASSES,
58717
59364
  styleCSSVars: PICKER_SPIN_STYLE_CSS_VARS,
58718
59365
  "data-vertical": vertical ? "" : undefined,
@@ -58737,14 +59384,21 @@ const Spin = ({
58737
59384
  readOnly: readOnly,
58738
59385
  disabled: disabled,
58739
59386
  loading: loading
58740
- // No frame of its own inside a frame, and no ring of its own
58741
- // either: the spin draws both (see the CSS above), and an outline
58742
- // of zero width is how a field stands down without its focus
58743
- // state being touched.
59387
+ // The field is written as big as the box around it: a size that
59388
+ // only grew the chevrons would be half a size.
59389
+ ,
59390
+
59391
+ size: sizeResolved
59392
+ // No frame of its own inside a frame: the spin draws it (see the
59393
+ // CSS above). The ring is the spin's too — an outline of zero width
59394
+ // is how a field stands down without its focus state being touched
59395
+ // — except inside a group, where the frame belongs to the group and
59396
+ // the ring stays on the field: the spins are typed into one at a
59397
+ // time, and the ring is what says which one holds the keyboard.
58744
59398
  ,
58745
59399
 
58746
59400
  variant: "discrete",
58747
- outlineWidth: "0",
59401
+ outlineWidth: group ? undefined : "0",
58748
59402
  textAlign: "center",
58749
59403
  expandX: true,
58750
59404
  uiAction: (valueNext, event) => {
@@ -58868,6 +59522,7 @@ const WayOut = ({
58868
59522
  unavailableMessage,
58869
59523
  label,
58870
59524
  onPress,
59525
+ onPointerDown,
58871
59526
  children
58872
59527
  }) => jsx(Box, {
58873
59528
  as: "span",
@@ -58914,6 +59569,7 @@ const WayOut = ({
58914
59569
  onClick: e => {
58915
59570
  e.preventDefault();
58916
59571
  },
59572
+ onPointerDown: onPointerDown,
58917
59573
  onMouseDown: e => {
58918
59574
  // No focus, no text selection: the keyboard is put on the middle below.
58919
59575
  e.preventDefault();
@@ -58964,6 +59620,122 @@ const compareValuesDefault = (a, b) => {
58964
59620
  };
58965
59621
  const renderValueDefault = value => String(value ?? "");
58966
59622
 
59623
+ /**
59624
+ * Several spins read as one value: "7h30" is an hour, not a 7 beside a 30. The
59625
+ * frame goes around the group, the spins inside give theirs up, and what is
59626
+ * written between them — an "h", a ":", a word — sits inside the frame with
59627
+ * them.
59628
+ *
59629
+ * A group IS a control: its named spins aggregate into `{ hour: …, minute: … }`
59630
+ * for the form around it, and `aggregateChildStates`/`distributeChildUIState`
59631
+ * turn that into whatever the group is really worth instead ("07:30", a number
59632
+ * of minutes) — one value in both directions, so the group can be driven by a
59633
+ * single `value`/`signal` like any other control. `TimeSpin` is that, for a
59634
+ * time of day.
59635
+ *
59636
+ * @type {import("ignore:preact").FunctionComponent<{
59637
+ * name?: string,
59638
+ * value?: any,
59639
+ * defaultValue?: any,
59640
+ * signal?: import("@preact/signals").Signal<any>,
59641
+ * aggregateChildStates?: (childUIStateControllers: any[]) => any,
59642
+ * distributeChildUIState?: (groupState: any, childUIStateController: any) => any,
59643
+ * children?: import("ignore:preact").ComponentChildren,
59644
+ * [key: string]: any,
59645
+ * }>}
59646
+ * Everything a box takes is taken here too — `width`, `borderWidth`,
59647
+ * `borderRadius`, `backgroundColor`: the frame is the group's, and its corners
59648
+ * are passed on to the spins sitting in them.
59649
+ */
59650
+ const SpinGroup = props => {
59651
+ import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
59652
+ const {
59653
+ size
59654
+ } = props;
59655
+ const defaultRef = useRef(null);
59656
+ props.ref = props.ref || defaultRef;
59657
+ const groupRef = props.ref;
59658
+ // Two digit fields side by side are filled the way a date or a code is: the
59659
+ // hour reaching its two digits moves on to the minutes, and Left/Right at
59660
+ // either end of a field walk between them.
59661
+ useInputGroup(groupRef);
59662
+ const [controlgroupRootProps, controlgroupProps, childrenWrapperProps] = useControlgroupProps(props, {
59663
+ allowCapture: true,
59664
+ wantRequesterButtonState: true,
59665
+ controlType: "control_group",
59666
+ stateType: "object",
59667
+ cascadeValidationToChildren: true,
59668
+ aggregateChildStates: props.aggregateChildStates,
59669
+ distributeChildUIState: props.distributeChildUIState
59670
+ });
59671
+ const {
59672
+ children
59673
+ } = controlgroupProps;
59674
+ const {
59675
+ readOnly,
59676
+ disabled,
59677
+ loading
59678
+ } = childrenWrapperProps;
59679
+ return jsxs(Box, {
59680
+ ...controlgroupRootProps,
59681
+ ...controlgroupProps,
59682
+ // Consumed by the group hook above; blanked after the spreads so they do
59683
+ // not reach the DOM as unknown attributes.
59684
+ aggregateChildStates: undefined,
59685
+ distributeChildUIState: undefined,
59686
+ baseClassName: "navi_spin_group",
59687
+ pseudoClasses: SPIN_GROUP_PSEUDO_CLASSES
59688
+ // What the frame and what sits between the spins are drawn from: the
59689
+ // spins fade themselves, and the group is what holds those two.
59690
+ ,
59691
+
59692
+ "data-readonly": readOnly ? "" : undefined,
59693
+ "data-disabled": disabled ? "" : undefined,
59694
+ "data-loading": loading ? "" : undefined,
59695
+ children: [jsx(LoadingOutline, {
59696
+ loading: loading,
59697
+ color: "var(--navi-loader-color)",
59698
+ inset: -2
59699
+ }), jsx(SpinGroupContext.Provider, {
59700
+ value: {
59701
+ size
59702
+ },
59703
+ children: jsx(ControlgroupChildrenWrapper, {
59704
+ ...childrenWrapperProps,
59705
+ // The group's name says where its value lands in the form; each spin
59706
+ // inside is named on its own.
59707
+ name: undefined,
59708
+ children: children
59709
+ })
59710
+ })]
59711
+ });
59712
+ };
59713
+
59714
+ // Lets a group hand down what is said once for all the spins in it (how big
59715
+ // they are written), and lets a spin know it is in one at all — which is what
59716
+ // moves the frame and the focus ring off the spin and onto the group.
59717
+ const SpinGroupContext = createContext(null);
59718
+ const SPIN_GROUP_PSEUDO_CLASSES = [":focus-within",
59719
+ // Nothing focuses the group for real — a spin inside it takes the keyboard —
59720
+ // but it is where the ring is drawn, so a demo can hold it there.
59721
+ ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
59722
+
59723
+ /**
59724
+ * SpinGroup.Separator — what is written between two spins ("h", ":", a word).
59725
+ * It stands as tall as they do, so what it says lands on their line.
59726
+ */
59727
+ const SpinGroupSeparator = ({
59728
+ children,
59729
+ ...rest
59730
+ }) => jsx(Box, {
59731
+ as: "span",
59732
+ ...rest,
59733
+ className: "navi_spin_group_separator",
59734
+ "aria-hidden": "true",
59735
+ children: children
59736
+ });
59737
+ SpinGroup.Separator = SpinGroupSeparator;
59738
+
58967
59739
  /**
58968
59740
  * A whole number one steps through and types into: the field IS the middle, so
58969
59741
  * the value can be typed as readily as stepped, and the two chevrons stand
@@ -58976,16 +59748,26 @@ const renderValueDefault = value => String(value ?? "");
58976
59748
  * min?: number,
58977
59749
  * max?: number,
58978
59750
  * step?: number,
59751
+ * pad?: number,
59752
+ * loop?: boolean,
58979
59753
  * [key: string]: any,
58980
59754
  * }>}
58981
59755
  * @param {number} [min=0] The lowest number one can reach; `max` is the
58982
59756
  * highest. They also bound what typing can produce, and how wide the field
58983
59757
  * is asked to be (see `maxLength`).
59758
+ * @param {boolean} [loop] The numbers go round: the step after `max` is `min`,
59759
+ * and the one before `min` is `max`. Both ends have to be known for that.
59760
+ * @param {number} [pad] How many digits the number is written on, zeroes in
59761
+ * front of it: `pad={2}` writes 0 as "00". What an hour, a minute or a second
59762
+ * is read as — a clock says "07:00", never "7:0". Only the way it is written:
59763
+ * what the control holds, and what a form carries, is the number itself.
58984
59764
  */
58985
59765
  const NumberSpin = ({
58986
59766
  min = 0,
58987
59767
  max,
58988
59768
  step = 1,
59769
+ pad,
59770
+ loop,
58989
59771
  vertical = true,
58990
59772
  growsUpward = true,
58991
59773
  controlProps,
@@ -58999,29 +59781,64 @@ const NumberSpin = ({
58999
59781
  step: step,
59000
59782
  vertical: vertical,
59001
59783
  fallbackValue: min,
59002
- valueAtStep: (value, count) => numberAtStep(value, count, min),
59784
+ valueAtStep: (value, count) => numberAtStep(value, count, {
59785
+ min,
59786
+ max,
59787
+ loop
59788
+ }),
59003
59789
  compareValues: (a, b) => Number(a) - Number(b),
59004
59790
  controlProps: {
59005
59791
  // The numeric keypad on a phone, and — through
59006
59792
  // input_resolver_mode — the "this field is full" event a group of
59007
59793
  // fields moves along on (see useInputGroup).
59008
- inputMode: "numeric",
59009
- maxLength: max === undefined ? undefined : String(max).length,
59794
+ "inputMode": "numeric",
59795
+ "maxLength": numberMaxLength(max, pad),
59796
+ // What the number is HELD as stays a number; `pad` is how it is written
59797
+ // in the field (see asControlHostValue).
59798
+ "navi-value-pad": pad,
59010
59799
  ...controlProps
59011
59800
  },
59012
59801
  ...rest
59013
59802
  });
59014
59803
 
59804
+ // How many characters the field takes: what the biggest number is worth, or
59805
+ // what the padding writes, whichever is longer.
59806
+ const numberMaxLength = (max, pad) => {
59807
+ if (max === undefined) {
59808
+ return pad;
59809
+ }
59810
+ const maxLength = String(max).length;
59811
+ if (pad && pad > maxLength) {
59812
+ return pad;
59813
+ }
59814
+ return maxLength;
59815
+ };
59816
+
59015
59817
  // One step away, bounds included: a step past `max` is a real number that
59016
59818
  // simply is not allowed, and Spin is the one that reads it as "nothing that
59017
59819
  // way" — clamping here would answer the chevron before it had a chance to say
59018
59820
  // so. A field mid-edit holding nothing (or nothing numeric) starts from `min`.
59019
- const numberAtStep = (value, count, min) => {
59821
+ //
59822
+ // Unless the numbers go round: the step after the last one is the first, so
59823
+ // the value handed back is always one Spin can reach and no chevron ever says
59824
+ // there is nothing that way. Going round needs both ends to be known — a
59825
+ // stretch open at one end has no other end to come back from.
59826
+ const numberAtStep = (value, count, {
59827
+ min,
59828
+ max,
59829
+ loop
59830
+ }) => {
59020
59831
  const number = Number(value);
59021
59832
  if (value === "" || value === undefined || Number.isNaN(number)) {
59022
59833
  return min;
59023
59834
  }
59024
- return number + count;
59835
+ const numberNext = number + count;
59836
+ if (!loop || max === undefined) {
59837
+ return numberNext;
59838
+ }
59839
+ const valueCount = max - min + 1;
59840
+ const offset = numberNext - min;
59841
+ return min + (offset % valueCount + valueCount) % valueCount;
59025
59842
  };
59026
59843
 
59027
59844
  /**
@@ -59155,6 +59972,212 @@ const addDays = (day, count) => {
59155
59972
  return dateToDay(date);
59156
59973
  };
59157
59974
 
59975
+ /**
59976
+ * A time of day, and a span between two of them, written the way a clock
59977
+ * writes them: two digits, an "h" (a ":" in English) between hours and
59978
+ * minutes, and one frame around the whole thing — "07h00" is one value, not a
59979
+ * 7 beside a 0.
59980
+ *
59981
+ * `TimeSpin` is a `SpinGroup` of two `NumberSpin`s: it takes and hands back a
59982
+ * single "HH:MM", so a form carries one field for it. `TimeRangeSpin` is two
59983
+ * of those, and it carries `{ start, end }` — with the one rule such a pair
59984
+ * always has, "the end comes after the start", checked when the form is sent
59985
+ * (see time_range_constraint.js).
59986
+ */
59987
+
59988
+ const HOUR_MAX = 23;
59989
+ const MINUTE_MAX = 59;
59990
+
59991
+ /**
59992
+ * @type {import("ignore:preact").FunctionComponent<{
59993
+ * name?: string,
59994
+ * value?: string,
59995
+ * defaultValue?: string,
59996
+ * signal?: import("@preact/signals").Signal<string>,
59997
+ * minuteStep?: number,
59998
+ * pad?: number,
59999
+ * loop?: boolean,
60000
+ * separator?: import("ignore:preact").ComponentChildren,
60001
+ * hourLabel?: string,
60002
+ * minuteLabel?: string,
60003
+ * [key: string]: any,
60004
+ * }>}
60005
+ * @param {string} [value] The time shown, as "HH:MM".
60006
+ * @param {number} [minuteStep=1] How many minutes a press on a minute chevron
60007
+ * covers — 15 for quarters of an hour.
60008
+ * @param {boolean} [loop=true] The hours and the minutes go round: 23h then 0h,
60009
+ * 59 minutes then 0. What a clock does — and there is no first or last hour
60010
+ * of a day to stop at. Say `loop={false}` for two ends one cannot step past.
60011
+ * @param {number} [pad=2] How many digits an hour and a minute are written on:
60012
+ * a clock says "07:00", never "7:0". Say `pad={0}` for the bare numbers.
60013
+ * @param {import("ignore:preact").ComponentChildren} [separator] What is written
60014
+ * between the hours and the minutes. "h" in French, ":" elsewhere.
60015
+ * Everything a box takes is taken here too — `width`, `borderRadius`, `size`.
60016
+ */
60017
+ const TimeSpin = ({
60018
+ minuteStep = 1,
60019
+ pad = 2,
60020
+ loop = true,
60021
+ separator = naviI18n("time.hour_separator"),
60022
+ hourLabel = naviI18n("time.hour_label"),
60023
+ minuteLabel = naviI18n("time.minute_label"),
60024
+ ...rest
60025
+ }) => jsxs(SpinGroup, {
60026
+ aggregateChildStates: aggregateTime,
60027
+ distributeChildUIState: distributeTime,
60028
+ ...rest,
60029
+ children: [jsx(NumberSpin, {
60030
+ name: "hour",
60031
+ min: 0,
60032
+ max: HOUR_MAX,
60033
+ pad: pad,
60034
+ loop: loop,
60035
+ controlProps: {
60036
+ "aria-label": hourLabel
60037
+ }
60038
+ }), jsx(SpinGroup.Separator, {
60039
+ children: separator
60040
+ }), jsx(NumberSpin, {
60041
+ name: "minute",
60042
+ min: 0,
60043
+ max: MINUTE_MAX,
60044
+ step: minuteStep,
60045
+ pad: pad,
60046
+ loop: loop,
60047
+ controlProps: {
60048
+ "aria-label": minuteLabel
60049
+ }
60050
+ })]
60051
+ });
60052
+
60053
+ // The two fields as one value, "HH:MM" — and nothing at all while one of them
60054
+ // is empty: half a time is not a time, and a form has nothing to send about it.
60055
+ const aggregateTime = childUIStateControllers => {
60056
+ let hour = "";
60057
+ let minute = "";
60058
+ for (const child of childUIStateControllers) {
60059
+ if (child.name === "hour") {
60060
+ hour = child.uiState ?? "";
60061
+ }
60062
+ if (child.name === "minute") {
60063
+ minute = child.uiState ?? "";
60064
+ }
60065
+ }
60066
+ if (hour === "" || minute === "") {
60067
+ return undefined;
60068
+ }
60069
+ return `${padTwo(hour)}:${padTwo(minute)}`;
60070
+ };
60071
+
60072
+ // The way back: what the group is set to (a picked value, a form being reset)
60073
+ // lands on the field it belongs to.
60074
+ const distributeTime = (groupState, childUIStateController) => {
60075
+ const parts = parseTime(groupState);
60076
+ if (!parts) {
60077
+ return undefined;
60078
+ }
60079
+ return parts[childUIStateController.name];
60080
+ };
60081
+ const parseTime = time => {
60082
+ if (typeof time !== "string") {
60083
+ return null;
60084
+ }
60085
+ const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
60086
+ if (!match) {
60087
+ return null;
60088
+ }
60089
+ // Numbers: what an hour and a minute are held as. How they are written —
60090
+ // "07" — is the field's business (see NumberSpin's `pad`).
60091
+ return {
60092
+ hour: Number(match[1]),
60093
+ minute: Number(match[2])
60094
+ };
60095
+ };
60096
+ const padTwo = value => String(value).padStart(2, "0");
60097
+
60098
+ /**
60099
+ * @type {import("ignore:preact").FunctionComponent<{
60100
+ * name?: string,
60101
+ * value?: { start?: string, end?: string },
60102
+ * defaultValue?: { start?: string, end?: string },
60103
+ * minuteStep?: number,
60104
+ * minDuration?: number,
60105
+ * pad?: number,
60106
+ * timeProps?: object,
60107
+ * loop?: boolean,
60108
+ * size?: string,
60109
+ * startLabel?: import("ignore:preact").ComponentChildren,
60110
+ * endLabel?: import("ignore:preact").ComponentChildren,
60111
+ * [key: string]: any,
60112
+ * }>}
60113
+ * @param {{ start?: string, end?: string }} [value] The span shown, as two
60114
+ * "HH:MM".
60115
+ * @param {import("ignore:preact").ComponentChildren} [startLabel] What is written
60116
+ * before the first time ("De"), and `endLabel` between the two ("à"). Say
60117
+ * `null` for neither.
60118
+ * @param {number} [minuteStep=1] How many minutes a press on a minute chevron
60119
+ * covers, on both times.
60120
+ * @param {object} [timeProps] Anything a `TimeSpin` takes, said once for both
60121
+ * of them — a radius, a width. `startTimeProps`/`endTimeProps` say it to one
60122
+ * of the two, and win over this one.
60123
+ * @param {number} [minDuration=0] How long the span must last at least, in
60124
+ * minutes. Zero by default: a span of no length is a span all the same, only
60125
+ * one that goes backwards is not. Checked when the form is sent, and the
60126
+ * answer is given on the end time.
60127
+ */
60128
+ const TimeRangeSpin = ({
60129
+ minuteStep = 1,
60130
+ minDuration = 0,
60131
+ pad = 2,
60132
+ loop = true,
60133
+ size,
60134
+ startLabel = naviI18n("time_range.from"),
60135
+ endLabel = naviI18n("time_range.to"),
60136
+ timeProps,
60137
+ startTimeProps,
60138
+ endTimeProps,
60139
+ ...rest
60140
+ }) => {
60141
+ const startId = useId();
60142
+ return jsxs(ControlGroup, {
60143
+ flex: true,
60144
+ alignY: "center",
60145
+ spacing: "s",
60146
+ size: size,
60147
+ ...rest,
60148
+ children: [startLabel === null ? null : jsx(Text, {
60149
+ size: size,
60150
+ children: startLabel
60151
+ }), jsx(TimeSpin, {
60152
+ id: startId,
60153
+ name: "start",
60154
+ minuteStep: minuteStep,
60155
+ pad: pad,
60156
+ loop: loop,
60157
+ size: size,
60158
+ ...timeProps,
60159
+ ...startTimeProps
60160
+ }), endLabel === null ? null : jsx(Text, {
60161
+ size: size,
60162
+ children: endLabel
60163
+ }), jsx(TimeSpin, {
60164
+ name: "end",
60165
+ minuteStep: minuteStep,
60166
+ pad: pad,
60167
+ loop: loop,
60168
+ size: size
60169
+ // Which time it comes after, and how much room there must be between
60170
+ // the two: said on the LATER of the two, so the answer is given where
60171
+ // the time one would have to move is (see time_range_constraint.js).
60172
+ ,
60173
+ "data-time-after": startId,
60174
+ "data-time-min-duration": minDuration,
60175
+ ...timeProps,
60176
+ ...endTimeProps
60177
+ })]
60178
+ });
60179
+ };
60180
+
59158
60181
  installImportMetaCssBuild(import.meta);// TOFIX: select in data then reset, it reset to red/blue instead of red/blue/green
59159
60182
  const css$p = /* css */`
59160
60183
  .navi_checkbox_group {
@@ -59575,228 +60598,6 @@ const formatIntlUnit = (unit, {
59575
60598
  }
59576
60599
  };
59577
60600
 
59578
- /**
59579
- * Wraps multiple inputs together and handles keyboard navigation and paste
59580
- * distribution between them.
59581
- *
59582
- * Keyboard navigation:
59583
- * ArrowRight at the end of an input moves focus to the next input.
59584
- * ArrowLeft at the start of an input moves focus to the previous input.
59585
- * navi_input_full (emitted when an input reaches maxLength) also moves forward.
59586
- *
59587
- * Paste distribution:
59588
- * When an input has a data-separator attribute, pasting a string that
59589
- * contains that separator (e.g. "27/04/1990" into a day input with
59590
- * data-separator="/") splits the text on each separator and fills the
59591
- * corresponding sub-inputs in order.
59592
- */
59593
- const useInputGroup = (ref) => {
59594
- const debugFocus = useDebugFocus();
59595
-
59596
- useEffect(() => {
59597
- const el = ref.current;
59598
- if (!el) {
59599
- return () => {};
59600
- }
59601
-
59602
- const getInputs = () =>
59603
- Array.from(el.querySelectorAll(".navi_control_input"));
59604
-
59605
- const focusInput = (input) => {
59606
- input.focus();
59607
- input.select();
59608
- };
59609
-
59610
- const handleKeyDown = (e) => {
59611
- if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
59612
- return;
59613
- }
59614
- const active = document.activeElement;
59615
- if (!isTextInputElement(active) || !el.contains(active)) {
59616
- return;
59617
- }
59618
- if (e.key === "ArrowRight") {
59619
- const allSelected =
59620
- active.selectionStart === 0 &&
59621
- active.selectionEnd === active.value.length;
59622
- const atEnd =
59623
- allSelected ||
59624
- (active.selectionStart === active.value.length &&
59625
- active.selectionEnd === active.value.length);
59626
- if (!atEnd) {
59627
- return;
59628
- }
59629
- const inputs = getInputs();
59630
- const idx = inputs.indexOf(active);
59631
- if (idx === -1) {
59632
- debugFocus(
59633
- e,
59634
- "InputGroup ArrowRight on non group input → do nothing",
59635
- );
59636
- return;
59637
- }
59638
- if (idx === inputs.length - 1) {
59639
- debugFocus(
59640
- e,
59641
- "InputGroup ArrowRight at end of last input → do nothing",
59642
- );
59643
- return;
59644
- }
59645
-
59646
- debugFocus(
59647
- e,
59648
- "InputGroup ArrowRight at end of input[%d] → focus input[%d]",
59649
- idx,
59650
- idx + 1,
59651
- );
59652
- e.preventDefault();
59653
- focusInput(inputs[idx + 1]);
59654
- return;
59655
- }
59656
- const allSelected =
59657
- active.selectionStart === 0 &&
59658
- active.selectionEnd === active.value.length;
59659
- const atStart =
59660
- allSelected ||
59661
- (active.selectionStart === 0 && active.selectionEnd === 0);
59662
- if (!atStart) {
59663
- return;
59664
- }
59665
- const inputs = getInputs();
59666
- const idx = inputs.indexOf(active);
59667
- if (idx === 0) {
59668
- return;
59669
- }
59670
- debugFocus(
59671
- e,
59672
- "InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
59673
- idx,
59674
- idx - 1,
59675
- );
59676
- e.preventDefault();
59677
- focusInput(inputs[idx - 1]);
59678
- };
59679
-
59680
- const handleNaviInputFull = (e) => {
59681
- if (!e.detail.event?.isTrusted) {
59682
- // Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
59683
- return;
59684
- }
59685
- const input = e.detail.event.currentTarget;
59686
- if (!el.contains(input)) {
59687
- return;
59688
- }
59689
- const inputs = getInputs();
59690
- const idx = inputs.indexOf(input);
59691
- if (idx === -1) {
59692
- return;
59693
- }
59694
- if (idx === inputs.length - 1) {
59695
- return;
59696
- }
59697
- const nextInput = inputs[idx + 1];
59698
- debugFocus(
59699
- e,
59700
- "InputGroup navi_input_full on input -> move to next input",
59701
- input,
59702
- nextInput,
59703
- );
59704
- e.preventDefault();
59705
- focusInput(nextInput);
59706
- };
59707
-
59708
- // const handlePaste = (e) => {
59709
- // const active = document.activeElement;
59710
- // if (!isTextInputElement(active) || !el.contains(active)) {
59711
- // return;
59712
- // }
59713
- // const inputs = getInputs();
59714
- // const startIdx = inputs.indexOf(active);
59715
- // if (startIdx === -1) {
59716
- // return;
59717
- // }
59718
- // const pastedText = e.clipboardData?.getData("text") ?? "";
59719
- // if (!pastedText) {
59720
- // return;
59721
- // }
59722
- // // Only intercept when the pasted text contains at least one separator
59723
- // // from the inputs starting at the focused position.
59724
- // const remainingInputs = inputs.slice(startIdx);
59725
- // const hasSeparatorMatch = remainingInputs.some(
59726
- // (input) =>
59727
- // input.dataset.separator &&
59728
- // pastedText.includes(input.dataset.separator),
59729
- // );
59730
- // if (!hasSeparatorMatch) {
59731
- // return;
59732
- // }
59733
- // e.preventDefault();
59734
- // let remaining = pastedText;
59735
- // let lastFilledIdx = startIdx;
59736
- // for (let i = 0; i < remainingInputs.length; i++) {
59737
- // const input = remainingInputs[i];
59738
- // const separator = input.dataset.separator;
59739
- // let part;
59740
- // if (separator && remaining.includes(separator)) {
59741
- // const sepIdx = remaining.indexOf(separator);
59742
- // part = remaining.slice(0, sepIdx);
59743
- // remaining = remaining.slice(sepIdx + separator.length);
59744
- // } else {
59745
- // part = remaining;
59746
- // remaining = "";
59747
- // }
59748
- // requestSubPaste(input, part, e);
59749
- // lastFilledIdx = startIdx + i;
59750
- // if (remaining === "") {
59751
- // break;
59752
- // }
59753
- // }
59754
- // focusInput(inputs[lastFilledIdx]);
59755
- // };
59756
-
59757
- el.addEventListener("keydown", handleKeyDown, { capture: true });
59758
- el.addEventListener("navi_input_full", handleNaviInputFull);
59759
- // el.addEventListener("paste", handlePaste, { capture: true });
59760
- return () => {
59761
- el.removeEventListener("keydown", handleKeyDown, { capture: true });
59762
- el.removeEventListener("navi_input_full", handleNaviInputFull);
59763
- // el.removeEventListener("paste", handlePaste, { capture: true });
59764
- };
59765
- }, [debugFocus]);
59766
- };
59767
-
59768
- // const requestSubPaste = (input, value, event) => {
59769
- // dispatchRequestInteraction(input, {
59770
- // event,
59771
- // name: "subpaste",
59772
- // allowed: () => {
59773
- // dispatchRequestSetUIState(input, value, { event });
59774
- // },
59775
- // });
59776
- // };
59777
-
59778
- const isTextInputElement = (el) => {
59779
- if (!el) {
59780
- return false;
59781
- }
59782
- if (el.tagName === "TEXTAREA") {
59783
- return true;
59784
- }
59785
- if (el.tagName !== "INPUT") {
59786
- return false;
59787
- }
59788
- const type = el.type || "text";
59789
- return (
59790
- type === "text" ||
59791
- type === "search" ||
59792
- type === "url" ||
59793
- type === "tel" ||
59794
- type === "email" ||
59795
- type === "password" ||
59796
- type === "number"
59797
- );
59798
- };
59799
-
59800
60601
  installImportMetaCssBuild(import.meta);const css$n = /* css */`
59801
60602
  .navi_input_duration {
59802
60603
  --duration-separator-spacing: 4px;
@@ -69182,5 +69983,5 @@ const UserSvg = () => jsx("svg", {
69182
69983
  })
69183
69984
  });
69184
69985
 
69185
- 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, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, 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 };
69986
+ 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, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, 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, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, 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 };
69186
69987
  //# sourceMappingURL=jsenv_navi.js.map