@jsenv/navi 0.29.31 → 0.29.33

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.
@@ -12304,6 +12419,30 @@ const swipeTypeOf = (axis, pulled) => {
12304
12419
  * `view-transition-name` must be unique per document, so only the application can
12305
12420
  * name what moves.
12306
12421
  *
12422
+ * WHEN THE PRESS BECOMES A HOLD: `grab`.
12423
+ *
12424
+ * interactions={{ toss: remove, grab: () => navigator.vibrate?.(10) }}
12425
+ *
12426
+ * The four above all answer the RELEASE, and between the press and the release
12427
+ * there is one instant that counts for the hand making the gesture: the one where
12428
+ * the object stops being pressed and starts being held. `grab` is that instant,
12429
+ * and it is the same one whichever way the drag was entered — a finger held still,
12430
+ * a mouse travelled a few pixels.
12431
+ *
12432
+ * It matters most where it is least visible. On a screen the held object is under
12433
+ * the thumb that hides it, so the only feedback available is the one that is felt;
12434
+ * without it the hand waits, doubts the press was heard, and lets go too early. A
12435
+ * vibration is the usual answer, but nothing here is about vibration — a sound, a
12436
+ * class, a measure are the same moment.
12437
+ *
12438
+ * It is told, not asked: `grab` reports, so what it returns is not waited on and
12439
+ * preventing its event does not call the gesture off. And it is not an interaction
12440
+ * on its own — declared without one of the four above there is no gesture for it
12441
+ * to be the beginning of.
12442
+ *
12443
+ * A `longpress` needs nothing of this: it already happens at the moment the hold
12444
+ * is acquired rather than at the release (see interaction_press.js).
12445
+ *
12307
12446
  * What the copy LOOKS like is the application's too. A copy of a transparent
12308
12447
  * element is invisible — a row usually gets its background from the list around it,
12309
12448
  * which the copy has left — so it is dressed through the attributes the gesture
@@ -12313,10 +12452,19 @@ const swipeTypeOf = (axis, pulled) => {
12313
12452
 
12314
12453
  const MOVE = "move";
12315
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";
12316
12459
  const TOSS = "toss";
12460
+ // The moment the press stops being a press and becomes a hold on the object.
12461
+ const GRAB = "grab";
12317
12462
 
12318
12463
  // What makes an element a place something can land, written by the detector itself.
12319
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";
12320
12468
  // Which axes the drag walks: "x", "y" or "xy". Its default is not the same for
12321
12469
  // every outcome — a list runs one way, and something being put somewhere goes
12322
12470
  // wherever it is put.
@@ -12329,20 +12477,30 @@ const TOSS_SPEED_ATTRIBUTE = "data-toss-speed";
12329
12477
 
12330
12478
  defineInteractionDetector({
12331
12479
  name: "drag",
12332
- claims: (type) => type === MOVE || type === REORDER || type === TOSS,
12480
+ claims: (type) =>
12481
+ type === MOVE ||
12482
+ type === REORDER ||
12483
+ type === LAND ||
12484
+ type === TOSS ||
12485
+ type === GRAB,
12333
12486
  setup: (element, trigger, { types, readConfig }) => {
12334
12487
  const canMove = types.includes(MOVE);
12335
12488
  const canReorder = types.includes(REORDER);
12489
+ const canLand = types.includes(LAND);
12336
12490
  const canToss = types.includes(TOSS);
12491
+ if (!canMove && !canReorder && !canLand && !canToss) {
12492
+ return undefined;
12493
+ }
12494
+ const tellsWhenGrabbed = types.includes(GRAB);
12337
12495
  // Read at setup rather than at the press: a container can say it, and it is
12338
12496
  // what the gesture is about rather than something it discovers.
12339
12497
  const axisHolder = element.closest(`[${AXIS_ATTRIBUTE}]`);
12340
12498
  const axes =
12341
12499
  axisHolder?.getAttribute(AXIS_ATTRIBUTE) ||
12342
12500
  // A list runs one way, and reordering walks it. Anything else goes wherever
12343
- // the hand takes it: a thing put somewhere has two axes to be put along, and
12344
- // a throw goes where it was thrown.
12345
- (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");
12346
12504
 
12347
12505
  if (canReorder) {
12348
12506
  element.setAttribute(REORDERABLE_ATTRIBUTE, "");
@@ -12352,14 +12510,22 @@ defineInteractionDetector({
12352
12510
  // SURROUNDINGS scroll on, which for a list is the axis the list runs on.
12353
12511
  element.setAttribute("data-drag-source", axes === "x" ? "x" : "");
12354
12512
 
12513
+ // What a release can mean, which is not all of what was declared: "grab" is a
12514
+ // moment, not an outcome, and the gesture must not read it as one.
12515
+ const effects = types.filter((type) => type !== GRAB);
12516
+
12355
12517
  const onPointerDown = (pointerDownEvent) => {
12356
12518
  // What this element says a release can mean. The gesture then runs only what
12357
12519
  // those need — no copy for a move, no drop hint for something that can only
12358
12520
  // be thrown away.
12359
- startDragTo(pointerDownEvent, types, {
12521
+ startDragTo(pointerDownEvent, effects, {
12360
12522
  draggedElement: element,
12361
12523
  // Nothing to land on when nothing reorders.
12362
- itemSelector: canReorder ? `[${REORDERABLE_ATTRIBUTE}]` : undefined,
12524
+ itemSelector: canLand
12525
+ ? `[${DROPPABLE_ATTRIBUTE}]`
12526
+ : canReorder
12527
+ ? `[${REORDERABLE_ATTRIBUTE}]`
12528
+ : undefined,
12363
12529
  getItemId: (itemElement) => itemElement.id,
12364
12530
  direction: { x: axes.includes("x"), y: axes.includes("y") },
12365
12531
  // Where it may go, said in the DOM. A thing that is put somewhere stays
@@ -12377,6 +12543,19 @@ defineInteractionDetector({
12377
12543
  longPressSlop: readConfig(SLOP_ATTRIBUTE, undefined),
12378
12544
  tossDistance: readConfig(TOSS_DISTANCE_ATTRIBUTE, undefined),
12379
12545
  tossSpeed: readConfig(TOSS_SPEED_ATTRIBUTE, undefined),
12546
+ // The one moment the gesture has that is not a release. Said here rather
12547
+ // than from the press that led to it, because the press is only one of the
12548
+ // two ways in: a finger holds still, a mouse travels a few pixels, and it
12549
+ // is the same instant — the object is now held. Nothing is done with what
12550
+ // comes back: this says something happened, it does not ask for work.
12551
+ onDragStart: tellsWhenGrabbed
12552
+ ? (gestureInfo) => {
12553
+ trigger(GRAB, pointerDownEvent, {
12554
+ pointerType: pointerDownEvent.pointerType,
12555
+ gestureInfo,
12556
+ });
12557
+ }
12558
+ : undefined,
12380
12559
  // Handed straight back in all three cases: what `trigger` returns is a
12381
12560
  // promise while the answer is still going, which is what the gesture waits
12382
12561
  // on before it lets go of what it carries.
@@ -12386,6 +12565,12 @@ defineInteractionDetector({
12386
12565
  toId,
12387
12566
  syncCloneWithDropTarget,
12388
12567
  }),
12568
+ onLand: (fromId, toId, syncCloneWithDropTarget) =>
12569
+ trigger(LAND, pointerDownEvent, {
12570
+ fromId,
12571
+ toId,
12572
+ syncCloneWithDropTarget,
12573
+ }),
12389
12574
  onToss: ({ gestureInfo }) =>
12390
12575
  trigger(TOSS, pointerDownEvent, {
12391
12576
  id: element.id,
@@ -14668,6 +14853,62 @@ const SINGLE_SPACE_CONSTRAINT = {
14668
14853
  };
14669
14854
  CONSTRAINT_ATTRIBUTE_SET.add("data-single-space");
14670
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
+
14671
14912
  /**
14672
14913
  * Custom form validation implementation
14673
14914
  *
@@ -14737,6 +14978,7 @@ const NAVI_CONSTRAINT_SET = new Set([
14737
14978
  MIN_LOWER_LETTER_CONSTRAINT,
14738
14979
  SAME_AS_CONSTRAINT,
14739
14980
  ONE_OF_CONSTRAINT,
14981
+ TIME_RANGE_CONSTRAINT,
14740
14982
  ]);
14741
14983
  const DEFAULT_CONSTRAINT_SET = new Set([
14742
14984
  ...STANDARD_CONSTRAINT_SET,
@@ -17000,6 +17242,7 @@ const resolveSpacingSize = (size, element, property = "padding") => {
17000
17242
  };
17001
17243
 
17002
17244
  const COLOR_KEYWORD_MAP = {
17245
+ primary: "var(--navi-color-primary)",
17003
17246
  secondary: "var(--navi-color-secondary)",
17004
17247
  emphasis: "var(--navi-color-emphasis)",
17005
17248
  discrete: "var(--navi-color-discrete)",
@@ -22096,6 +22339,7 @@ const CONTROL_ATTRIBUTE_SET = new Set([
22096
22339
 
22097
22340
  // "ui-action-target",
22098
22341
  "navi-input-type",
22342
+ "navi-value-pad",
22099
22343
  "navi-control-proxy-for",
22100
22344
  "navi-command-proxy-for",
22101
22345
  "navi-command-target",
@@ -23360,6 +23604,12 @@ const useUIGroupStateController = (
23360
23604
  const debugUIGroup = useDebugUIState();
23361
23605
  const debugFocus = useDebugFocus();
23362
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;
23363
23613
  const defaults = GROUP_DEFAULTS[controlType] ?? GROUP_DEFAULTS[stateType];
23364
23614
  const resolvedChildControlFilter =
23365
23615
  childControlFilter ?? defaults?.childControlFilter ?? null;
@@ -23533,6 +23783,7 @@ const useUIGroupStateController = (
23533
23783
  setUIState: (newUIState, e) => {
23534
23784
  if (
23535
23785
  stateType === "object" &&
23786
+ stateShapeIsTheDefaultOne &&
23536
23787
  (newUIState === null || typeof newUIState !== "object")
23537
23788
  ) {
23538
23789
  console.warn(
@@ -23541,7 +23792,11 @@ const useUIGroupStateController = (
23541
23792
  );
23542
23793
  return;
23543
23794
  }
23544
- if (stateType === "array" && !Array.isArray(newUIState)) {
23795
+ if (
23796
+ stateType === "array" &&
23797
+ stateShapeIsTheDefaultOne &&
23798
+ !Array.isArray(newUIState)
23799
+ ) {
23545
23800
  console.warn(
23546
23801
  `[${controlType}] setUIState received a non-array value: ${JSON.stringify(newUIState)} (expected an array). Ignoring.`,
23547
23802
  newUIState,
@@ -24273,7 +24528,10 @@ const useControlProps = (props, {
24273
24528
  controlType,
24274
24529
  id: props.id,
24275
24530
  type: props.type,
24276
- 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"]
24277
24535
  });
24278
24536
  return {
24279
24537
  value: domValue
@@ -24284,6 +24542,13 @@ const useControlProps = (props, {
24284
24542
  if (!el) {
24285
24543
  return;
24286
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
+ }
24287
24552
  const domProps = toDomProps(newUIState);
24288
24553
  Object.assign(el, domProps);
24289
24554
  debugUIState(e, `syncDomState: updated to ${getElementSignature(el)}`, domProps);
@@ -24871,9 +25136,32 @@ const useControlProps = (props, {
24871
25136
  onPaste,
24872
25137
  onInput
24873
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
+ }
24874
25154
  }
24875
25155
  const uiState = uiStateController.uiStateSignal.peek();
24876
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
+ }
24877
25165
  Object.assign(controlHostProps, domProps);
24878
25166
  return [controlRootProps, controlHostProps, {
24879
25167
  uiStateController
@@ -25013,17 +25301,26 @@ const createControlInfo = (props, {
25013
25301
  };
25014
25302
  // color, radio, image, file etc do not support readonly
25015
25303
  const INPUT_TYPE_SUPPORTING_READONLY_SET = new Set(["text", "date", "datetime-local", "email", "month", "number", "password", "search", "tel", "time", "url", "week"]);
25016
- const useReadOnlyUncontrolled = (props, controlInfo) => {
25017
- if (!controlInfo.hasStateProp) {
25018
- return false;
25019
- }
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 => {
25020
25311
  const isProxy = Boolean(props["navi-control-proxy-for"]);
25021
25312
  const formContext = useContext(FormContext);
25022
25313
  const parentUIStateController = useContext(ParentUIStateControllerContext);
25023
- const controlled = props.signal ||
25314
+ return Boolean(props.signal ||
25024
25315
  // a bound signal is written back on uiAction → interactive
25025
- props.uiAction || props.action || formContext || parentUIStateController || isProxy || props.command;
25026
- 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) {
25027
25324
  return false;
25028
25325
  }
25029
25326
  if (
@@ -25069,9 +25366,12 @@ const useControlgroupProps = (props, {
25069
25366
  cascadeValidationToChildren
25070
25367
  });
25071
25368
  const [boundAction] = useActionBoundToOneParam(action, uiGroupStateController.uiStateSignal);
25072
- // Mirror single-input behaviour: a controlled value with no handler makes the
25073
- // group read-only so children don't appear interactive when they can't change.
25074
- 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;
25075
25375
  if (implicitReadOnly && !props.readOnly) {
25076
25376
  props.readOnly = true;
25077
25377
  }
@@ -44882,20 +45182,16 @@ const InputModeNumericOrDecimal = props => {
44882
45182
  return;
44883
45183
  }
44884
45184
  const input = e.currentTarget;
44885
- let maxLength;
44886
- const maxLengthProp = input.maxLength;
44887
- if (maxLengthProp === -1) {
45185
+ let maxLength = input.maxLength;
45186
+ if (maxLength === -1) {
44888
45187
  const naviMaxLengthAttr = input.getAttribute("navi-max-length");
44889
- if (naviMaxLengthAttr === null) {
44890
- // no max length
44891
- return;
44892
- }
44893
- maxLength = Number(naviMaxLengthAttr);
45188
+ maxLength = naviMaxLengthAttr === null ? undefined : Number(naviMaxLengthAttr);
44894
45189
  }
44895
- if (input.value.length < maxLength) {
45190
+ const caretAtEnd = input.selectionStart === input.value.length;
45191
+ if (!caretAtEnd) {
44896
45192
  return;
44897
45193
  }
44898
- if (input.selectionStart !== maxLength) {
45194
+ if (!isFull(input, maxLength)) {
44899
45195
  return;
44900
45196
  }
44901
45197
  // Field is full and caret is at the end: notify listeners then
@@ -44904,7 +45200,11 @@ const InputModeNumericOrDecimal = props => {
44904
45200
  const allowed = dispatchPublicCustomEvent(input, "navi_input_full", {
44905
45201
  event: e
44906
45202
  });
44907
- 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) {
44908
45208
  input.select();
44909
45209
  }
44910
45210
  },
@@ -44921,6 +45221,32 @@ const InputModeNumericOrDecimal = props => {
44921
45221
  });
44922
45222
  };
44923
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
+
44924
45250
  // hum il manque le faire de request interaction ici
44925
45251
  const performArrowUpDown = e => {
44926
45252
  const input = e.currentTarget;
@@ -50318,6 +50644,228 @@ const toDate = (value, parseString) => {
50318
50644
  return null;
50319
50645
  };
50320
50646
 
50647
+ /**
50648
+ * Wraps multiple inputs together and handles keyboard navigation and paste
50649
+ * distribution between them.
50650
+ *
50651
+ * Keyboard navigation:
50652
+ * ArrowRight at the end of an input moves focus to the next input.
50653
+ * ArrowLeft at the start of an input moves focus to the previous input.
50654
+ * navi_input_full (emitted when an input reaches maxLength) also moves forward.
50655
+ *
50656
+ * Paste distribution:
50657
+ * When an input has a data-separator attribute, pasting a string that
50658
+ * contains that separator (e.g. "27/04/1990" into a day input with
50659
+ * data-separator="/") splits the text on each separator and fills the
50660
+ * corresponding sub-inputs in order.
50661
+ */
50662
+ const useInputGroup = (ref) => {
50663
+ const debugFocus = useDebugFocus();
50664
+
50665
+ useEffect(() => {
50666
+ const el = ref.current;
50667
+ if (!el) {
50668
+ return () => {};
50669
+ }
50670
+
50671
+ const getInputs = () =>
50672
+ Array.from(el.querySelectorAll(".navi_control_input"));
50673
+
50674
+ const focusInput = (input) => {
50675
+ input.focus();
50676
+ input.select();
50677
+ };
50678
+
50679
+ const handleKeyDown = (e) => {
50680
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
50681
+ return;
50682
+ }
50683
+ const active = document.activeElement;
50684
+ if (!isTextInputElement(active) || !el.contains(active)) {
50685
+ return;
50686
+ }
50687
+ if (e.key === "ArrowRight") {
50688
+ const allSelected =
50689
+ active.selectionStart === 0 &&
50690
+ active.selectionEnd === active.value.length;
50691
+ const atEnd =
50692
+ allSelected ||
50693
+ (active.selectionStart === active.value.length &&
50694
+ active.selectionEnd === active.value.length);
50695
+ if (!atEnd) {
50696
+ return;
50697
+ }
50698
+ const inputs = getInputs();
50699
+ const idx = inputs.indexOf(active);
50700
+ if (idx === -1) {
50701
+ debugFocus(
50702
+ e,
50703
+ "InputGroup ArrowRight on non group input → do nothing",
50704
+ );
50705
+ return;
50706
+ }
50707
+ if (idx === inputs.length - 1) {
50708
+ debugFocus(
50709
+ e,
50710
+ "InputGroup ArrowRight at end of last input → do nothing",
50711
+ );
50712
+ return;
50713
+ }
50714
+
50715
+ debugFocus(
50716
+ e,
50717
+ "InputGroup ArrowRight at end of input[%d] → focus input[%d]",
50718
+ idx,
50719
+ idx + 1,
50720
+ );
50721
+ e.preventDefault();
50722
+ focusInput(inputs[idx + 1]);
50723
+ return;
50724
+ }
50725
+ const allSelected =
50726
+ active.selectionStart === 0 &&
50727
+ active.selectionEnd === active.value.length;
50728
+ const atStart =
50729
+ allSelected ||
50730
+ (active.selectionStart === 0 && active.selectionEnd === 0);
50731
+ if (!atStart) {
50732
+ return;
50733
+ }
50734
+ const inputs = getInputs();
50735
+ const idx = inputs.indexOf(active);
50736
+ if (idx === 0) {
50737
+ return;
50738
+ }
50739
+ debugFocus(
50740
+ e,
50741
+ "InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
50742
+ idx,
50743
+ idx - 1,
50744
+ );
50745
+ e.preventDefault();
50746
+ focusInput(inputs[idx - 1]);
50747
+ };
50748
+
50749
+ const handleNaviInputFull = (e) => {
50750
+ if (!e.detail.event?.isTrusted) {
50751
+ // Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
50752
+ return;
50753
+ }
50754
+ const input = e.detail.event.currentTarget;
50755
+ if (!el.contains(input)) {
50756
+ return;
50757
+ }
50758
+ const inputs = getInputs();
50759
+ const idx = inputs.indexOf(input);
50760
+ if (idx === -1) {
50761
+ return;
50762
+ }
50763
+ if (idx === inputs.length - 1) {
50764
+ return;
50765
+ }
50766
+ const nextInput = inputs[idx + 1];
50767
+ debugFocus(
50768
+ e,
50769
+ "InputGroup navi_input_full on input -> move to next input",
50770
+ input,
50771
+ nextInput,
50772
+ );
50773
+ e.preventDefault();
50774
+ focusInput(nextInput);
50775
+ };
50776
+
50777
+ // const handlePaste = (e) => {
50778
+ // const active = document.activeElement;
50779
+ // if (!isTextInputElement(active) || !el.contains(active)) {
50780
+ // return;
50781
+ // }
50782
+ // const inputs = getInputs();
50783
+ // const startIdx = inputs.indexOf(active);
50784
+ // if (startIdx === -1) {
50785
+ // return;
50786
+ // }
50787
+ // const pastedText = e.clipboardData?.getData("text") ?? "";
50788
+ // if (!pastedText) {
50789
+ // return;
50790
+ // }
50791
+ // // Only intercept when the pasted text contains at least one separator
50792
+ // // from the inputs starting at the focused position.
50793
+ // const remainingInputs = inputs.slice(startIdx);
50794
+ // const hasSeparatorMatch = remainingInputs.some(
50795
+ // (input) =>
50796
+ // input.dataset.separator &&
50797
+ // pastedText.includes(input.dataset.separator),
50798
+ // );
50799
+ // if (!hasSeparatorMatch) {
50800
+ // return;
50801
+ // }
50802
+ // e.preventDefault();
50803
+ // let remaining = pastedText;
50804
+ // let lastFilledIdx = startIdx;
50805
+ // for (let i = 0; i < remainingInputs.length; i++) {
50806
+ // const input = remainingInputs[i];
50807
+ // const separator = input.dataset.separator;
50808
+ // let part;
50809
+ // if (separator && remaining.includes(separator)) {
50810
+ // const sepIdx = remaining.indexOf(separator);
50811
+ // part = remaining.slice(0, sepIdx);
50812
+ // remaining = remaining.slice(sepIdx + separator.length);
50813
+ // } else {
50814
+ // part = remaining;
50815
+ // remaining = "";
50816
+ // }
50817
+ // requestSubPaste(input, part, e);
50818
+ // lastFilledIdx = startIdx + i;
50819
+ // if (remaining === "") {
50820
+ // break;
50821
+ // }
50822
+ // }
50823
+ // focusInput(inputs[lastFilledIdx]);
50824
+ // };
50825
+
50826
+ el.addEventListener("keydown", handleKeyDown, { capture: true });
50827
+ el.addEventListener("navi_input_full", handleNaviInputFull);
50828
+ // el.addEventListener("paste", handlePaste, { capture: true });
50829
+ return () => {
50830
+ el.removeEventListener("keydown", handleKeyDown, { capture: true });
50831
+ el.removeEventListener("navi_input_full", handleNaviInputFull);
50832
+ // el.removeEventListener("paste", handlePaste, { capture: true });
50833
+ };
50834
+ }, [debugFocus]);
50835
+ };
50836
+
50837
+ // const requestSubPaste = (input, value, event) => {
50838
+ // dispatchRequestInteraction(input, {
50839
+ // event,
50840
+ // name: "subpaste",
50841
+ // allowed: () => {
50842
+ // dispatchRequestSetUIState(input, value, { event });
50843
+ // },
50844
+ // });
50845
+ // };
50846
+
50847
+ const isTextInputElement = (el) => {
50848
+ if (!el) {
50849
+ return false;
50850
+ }
50851
+ if (el.tagName === "TEXTAREA") {
50852
+ return true;
50853
+ }
50854
+ if (el.tagName !== "INPUT") {
50855
+ return false;
50856
+ }
50857
+ const type = el.type || "text";
50858
+ return (
50859
+ type === "text" ||
50860
+ type === "search" ||
50861
+ type === "url" ||
50862
+ type === "tel" ||
50863
+ type === "email" ||
50864
+ type === "password" ||
50865
+ type === "number"
50866
+ );
50867
+ };
50868
+
50321
50869
  // When a component render a prop that can be anything (js value of preact element)
50322
50870
  // 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
50323
50871
  const renderSafe = (value) => {
@@ -53356,6 +53904,14 @@ const css$v = /* css */`
53356
53904
  text-transform: uppercase;
53357
53905
  letter-spacing: 0.05em;
53358
53906
  }
53907
+
53908
+ /* A group whose rows all failed the search keeps its height (its rows are
53909
+ still there, invisible) — the label must disappear with them, otherwise
53910
+ the list shows a title standing over nothing. Same aria-hidden + inert
53911
+ pair as the rows themselves. */
53912
+ &[aria-hidden="true"][inert] {
53913
+ opacity: 0;
53914
+ }
53359
53915
  }
53360
53916
  .navi_list_item_group_list {
53361
53917
  display: flex;
@@ -56475,6 +57031,14 @@ const ListItemGroup = ({
56475
57031
  }) => {
56476
57032
  const groupId = useId();
56477
57033
  const groupTracker = useItemTracker();
57034
+ const searchNoMatchMode = useContext(SearchNoMatchModeContext);
57035
+ const groupItemCount = groupTracker.countSignal.value;
57036
+ const groupNoMatchCount = groupTracker.noMatchCountSignal.value;
57037
+ // Every row of this group failed the search: the label has nothing left to
57038
+ // title. "remove" empties the group on its own (and hiddenWhileEmpty takes it
57039
+ // out of the flow), "muted" keeps the rows readable so the label stays useful
57040
+ // — only "invisible_and_inert" would leave a title floating over blank space.
57041
+ const labelHidden = searchNoMatchMode === "invisible_and_inert" && groupNoMatchCount > 0 && groupNoMatchCount === groupItemCount;
56478
57042
  const groupRef = useRef(null);
56479
57043
  const labelRef = useRef(null);
56480
57044
  useDisplayedLayoutEffect(labelRef, labelEl => {
@@ -56496,7 +57060,9 @@ const ListItemGroup = ({
56496
57060
  ref: labelRef,
56497
57061
  id: groupId,
56498
57062
  className: "navi_list_item_group_label",
56499
- role: "presentation"
57063
+ role: "presentation",
57064
+ "aria-hidden": labelHidden ? "true" : undefined,
57065
+ inert: labelHidden ? true : undefined
56500
57066
  // eslint-disable-next-line react/no-unknown-property
56501
57067
  ,
56502
57068
 
@@ -58152,6 +58718,15 @@ const css$q = /* css */`
58152
58718
  --picker-spin-padding-x-default: var(--navi-picker-padding-x-default);
58153
58719
  --picker-spin-padding-y-default: var(--navi-picker-padding-y-default);
58154
58720
  }
58721
+ /* Written in what it is written with, for a value one TYPES: a field is as
58722
+ wide as the digits in it and no wider, so the room around them has to
58723
+ come from here — and the two chevrons, which take the same, are then big
58724
+ enough to be aimed at with a finger. Said as a default, so a padding prop
58725
+ still wins. */
58726
+ .navi_picker_spin:has(> .navi_picker_spin_middle > .navi_input) {
58727
+ --picker-spin-padding-x-default: 0.6em;
58728
+ --picker-spin-padding-y-default: 0.25em;
58729
+ }
58155
58730
  }
58156
58731
 
58157
58732
  .navi_picker_spin {
@@ -58283,6 +58858,16 @@ const css$q = /* css */`
58283
58858
  min-width: 0;
58284
58859
  flex: 1 1 auto;
58285
58860
  }
58861
+ /* The same room around a value one TYPES as around one one picks (the
58862
+ [data-slide] rule below writes it there): without it the column is as
58863
+ narrow as the two digits in it, and a number pressed against both sides
58864
+ reads as a field too small for what it holds. Handed to the field as its
58865
+ own padding rather than kept by the middle: the field then IS the column —
58866
+ it fills it, and a click anywhere in it lands on the caret. */
58867
+ .navi_picker_spin_middle > .navi_input {
58868
+ --padding-right: var(--x-picker-spin-padding-right);
58869
+ --padding-left: var(--x-picker-spin-padding-left);
58870
+ }
58286
58871
  /* Where the padding lands: all four sides on the value, the two vertical
58287
58872
  ones on the chevrons below — the same number above and below is what makes
58288
58873
  the three one line rather than three boxes, while sideways it is the room
@@ -58360,8 +58945,19 @@ const css$q = /* css */`
58360
58945
  aspect-ratio: 1;
58361
58946
  justify-content: center;
58362
58947
  }
58948
+ /* Standing up, a chevron takes the whole width and whatever height is left
58949
+ over: a box taller than the three pieces in it (a height of its own, room
58950
+ asked for around the value) would otherwise leave a strip of nothing
58951
+ between the chevron and the border, and one pressing what looks like the
58952
+ bottom of the box would hit nothing. */
58363
58953
  .navi_picker_spin[data-vertical] > .navi_picker_spin_way_out {
58364
58954
  width: 100%;
58955
+ height: auto;
58956
+ min-height: calc(
58957
+ 1lh + var(--x-picker-spin-padding-top) +
58958
+ var(--x-picker-spin-padding-bottom)
58959
+ );
58960
+ flex: 1 0 auto;
58365
58961
  justify-content: center;
58366
58962
  }
58367
58963
  /* The corners of the box belong to what sits in them: a chevron in the corner
@@ -58393,6 +58989,92 @@ const css$q = /* css */`
58393
58989
  border-end-end-radius: inherit;
58394
58990
  border-end-start-radius: inherit;
58395
58991
  }
58992
+
58993
+ /* ── SpinGroup ─────────────────────────────────────────────────────────────
58994
+ Several spins read as one value: an hour is "7h30", not a 7 next to a 30.
58995
+ So the frame goes around the group, the spins inside give theirs up, and
58996
+ what sits between them (an "h", a ":") is inside the frame with them. */
58997
+ .navi_spin_group {
58998
+ /* What the loading outline is drawn around. */
58999
+ position: relative;
59000
+ display: inline-flex;
59001
+ align-items: center;
59002
+ font-size: var(--navi-control-font-size);
59003
+ font-family: var(--navi-control-font-family);
59004
+ border: var(--navi-control-border-width) solid
59005
+ var(--navi-control-border-color);
59006
+ border-radius: var(--navi-control-border-radius);
59007
+ outline-width: var(--navi-focus-outline-width);
59008
+ outline-color: var(--navi-focus-outline-color);
59009
+ outline-offset: 0px;
59010
+ -webkit-tap-highlight-color: var(--navi-control-tap-highlight-color);
59011
+ }
59012
+ /* A value one PICKS has nowhere of its own to wear a ring — its middle is a
59013
+ container that hands the ring over (data-focus-outline-delegate) — so the
59014
+ group wears it for that spin. A value one TYPES keeps its own, on the
59015
+ field: the spins in a group are edited one at a time, and the ring is what
59016
+ says which one the keyboard is in. */
59017
+ .navi_spin_group[data-focus-visible],
59018
+ .navi_spin_group:has([data-focus-outline-delegate][data-focus-visible]) {
59019
+ outline-style: solid;
59020
+ }
59021
+ .navi_spin_group .navi_picker_spin {
59022
+ border: none;
59023
+ border-radius: 0;
59024
+ }
59025
+ /* The corners of the group belong to the spins sitting in them, and through
59026
+ them to their chevrons, which are rounded by whatever their spin is. */
59027
+ .navi_spin_group > .navi_picker_spin:first-child {
59028
+ border-start-start-radius: inherit;
59029
+ border-end-start-radius: inherit;
59030
+ }
59031
+ .navi_spin_group > .navi_picker_spin:last-child {
59032
+ border-start-end-radius: inherit;
59033
+ border-end-end-radius: inherit;
59034
+ }
59035
+ .navi_spin_group .navi_picker_spin[data-focus-visible],
59036
+ .navi_spin_group
59037
+ .navi_picker_spin:has([data-focus-outline-delegate][data-focus-visible]),
59038
+ .navi_spin_group .navi_picker_spin:has(.navi_input[data-focus-visible]) {
59039
+ outline-style: none;
59040
+ }
59041
+ /* Fading the frame is the group's to do, since the frame is the group's. */
59042
+ .navi_spin_group[data-readonly],
59043
+ .navi_spin_group[data-loading] {
59044
+ border-color: color-mix(
59045
+ in srgb,
59046
+ var(--navi-control-border-color) 45%,
59047
+ transparent
59048
+ );
59049
+ }
59050
+ .navi_spin_group[data-disabled] {
59051
+ color: color-mix(in srgb, currentColor 40%, transparent);
59052
+ border-color: color-mix(
59053
+ in srgb,
59054
+ var(--navi-control-border-color) 30%,
59055
+ transparent
59056
+ );
59057
+ }
59058
+ /* As tall as the spins beside it, so what it says lands on their line. */
59059
+ .navi_spin_group_separator {
59060
+ display: flex;
59061
+ align-items: center;
59062
+ align-self: stretch;
59063
+ justify-content: center;
59064
+ color: inherit;
59065
+ white-space: nowrap;
59066
+ user-select: none;
59067
+ }
59068
+ /* The "h" fades with the values it sits between: it is one control, and a
59069
+ word left black beside two grey numbers reads as half a control out of
59070
+ service. Same mixes as the spins' own (see above). */
59071
+ .navi_spin_group[data-readonly] .navi_spin_group_separator,
59072
+ .navi_spin_group[data-loading] .navi_spin_group_separator {
59073
+ color: color-mix(in srgb, currentColor 60%, transparent);
59074
+ }
59075
+ .navi_spin_group[data-disabled] .navi_spin_group_separator {
59076
+ color: color-mix(in srgb, currentColor 40%, transparent);
59077
+ }
58396
59078
  `;
58397
59079
 
58398
59080
  /**
@@ -58485,6 +59167,7 @@ const Spin = ({
58485
59167
  readOnly,
58486
59168
  disabled,
58487
59169
  loading,
59170
+ size,
58488
59171
  maxLines,
58489
59172
  previousLabel,
58490
59173
  nextLabel,
@@ -58492,6 +59175,10 @@ const Spin = ({
58492
59175
  }) => {
58493
59176
  import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
58494
59177
  const id = useId();
59178
+ // What the group around it says, when there is one: how big the whole thing
59179
+ // is written is said once, on the group, and every spin in it follows.
59180
+ const group = useContext(SpinGroupContext);
59181
+ const sizeResolved = size ?? group?.size;
58495
59182
  const containerId = `${id}_values`;
58496
59183
  const controlId = `${id}_control`;
58497
59184
  // The control holds the value, and it is asked rather than shadowed:
@@ -58618,7 +59305,19 @@ const Spin = ({
58618
59305
  // it in this spin, so one can keep going with the keys where one was. Found
58619
59306
  // in the middle rather than by id — a field's id lands on the box around it,
58620
59307
  // and what takes the keyboard is the input inside.
59308
+ //
59309
+ // Not under a finger: focusing a field on a phone raises the on-screen
59310
+ // keyboard over half the page, and someone pressing a chevron is stepping
59311
+ // through values, not about to type one. With a mouse the opposite is true —
59312
+ // one presses once and then keeps going with the arrow keys — so the keyboard
59313
+ // is put back where it was. Which one it is comes from the pointer that
59314
+ // started the press (same convention as button_ui.jsx and
59315
+ // use_autoselect_read_only.js).
59316
+ const wayOutPointerTypeRef = useRef(null);
58621
59317
  const focusMiddle = () => {
59318
+ if (wayOutPointerTypeRef.current === "touch") {
59319
+ return;
59320
+ }
58622
59321
  const target = editable ? middleRef.current?.querySelector(".navi_control_input") : document.getElementById(containerId);
58623
59322
  target?.focus({
58624
59323
  preventScroll: true
@@ -58638,6 +59337,9 @@ const Spin = ({
58638
59337
  const isNext = atStart ? startIsNext : !startIsNext;
58639
59338
  return jsx(WayOut, {
58640
59339
  atStart: atStart,
59340
+ onPointerDown: e => {
59341
+ wayOutPointerTypeRef.current = e.pointerType;
59342
+ },
58641
59343
  unavailableMessage: wayOutMessage(atStart ? startAllowed : endAllowed, isNext ? "spin.nothing_after" : "spin.nothing_before"),
58642
59344
  label: isNext ? nextLabel ?? naviI18n("spin.next") : previousLabel ?? naviI18n("spin.previous"),
58643
59345
  onPress: e => {
@@ -58665,6 +59367,7 @@ const Spin = ({
58665
59367
  // asked for by hand (pseudoState) as well as held for real.
58666
59368
  ,
58667
59369
 
59370
+ size: sizeResolved,
58668
59371
  pseudoClasses: PICKER_SPIN_PSEUDO_CLASSES,
58669
59372
  styleCSSVars: PICKER_SPIN_STYLE_CSS_VARS,
58670
59373
  "data-vertical": vertical ? "" : undefined,
@@ -58689,14 +59392,21 @@ const Spin = ({
58689
59392
  readOnly: readOnly,
58690
59393
  disabled: disabled,
58691
59394
  loading: loading
58692
- // No frame of its own inside a frame, and no ring of its own
58693
- // either: the spin draws both (see the CSS above), and an outline
58694
- // of zero width is how a field stands down without its focus
58695
- // state being touched.
59395
+ // The field is written as big as the box around it: a size that
59396
+ // only grew the chevrons would be half a size.
59397
+ ,
59398
+
59399
+ size: sizeResolved
59400
+ // No frame of its own inside a frame: the spin draws it (see the
59401
+ // CSS above). The ring is the spin's too — an outline of zero width
59402
+ // is how a field stands down without its focus state being touched
59403
+ // — except inside a group, where the frame belongs to the group and
59404
+ // the ring stays on the field: the spins are typed into one at a
59405
+ // time, and the ring is what says which one holds the keyboard.
58696
59406
  ,
58697
59407
 
58698
59408
  variant: "discrete",
58699
- outlineWidth: "0",
59409
+ outlineWidth: group ? undefined : "0",
58700
59410
  textAlign: "center",
58701
59411
  expandX: true,
58702
59412
  uiAction: (valueNext, event) => {
@@ -58820,6 +59530,7 @@ const WayOut = ({
58820
59530
  unavailableMessage,
58821
59531
  label,
58822
59532
  onPress,
59533
+ onPointerDown,
58823
59534
  children
58824
59535
  }) => jsx(Box, {
58825
59536
  as: "span",
@@ -58866,6 +59577,7 @@ const WayOut = ({
58866
59577
  onClick: e => {
58867
59578
  e.preventDefault();
58868
59579
  },
59580
+ onPointerDown: onPointerDown,
58869
59581
  onMouseDown: e => {
58870
59582
  // No focus, no text selection: the keyboard is put on the middle below.
58871
59583
  e.preventDefault();
@@ -58916,6 +59628,122 @@ const compareValuesDefault = (a, b) => {
58916
59628
  };
58917
59629
  const renderValueDefault = value => String(value ?? "");
58918
59630
 
59631
+ /**
59632
+ * Several spins read as one value: "7h30" is an hour, not a 7 beside a 30. The
59633
+ * frame goes around the group, the spins inside give theirs up, and what is
59634
+ * written between them — an "h", a ":", a word — sits inside the frame with
59635
+ * them.
59636
+ *
59637
+ * A group IS a control: its named spins aggregate into `{ hour: …, minute: … }`
59638
+ * for the form around it, and `aggregateChildStates`/`distributeChildUIState`
59639
+ * turn that into whatever the group is really worth instead ("07:30", a number
59640
+ * of minutes) — one value in both directions, so the group can be driven by a
59641
+ * single `value`/`signal` like any other control. `TimeSpin` is that, for a
59642
+ * time of day.
59643
+ *
59644
+ * @type {import("ignore:preact").FunctionComponent<{
59645
+ * name?: string,
59646
+ * value?: any,
59647
+ * defaultValue?: any,
59648
+ * signal?: import("@preact/signals").Signal<any>,
59649
+ * aggregateChildStates?: (childUIStateControllers: any[]) => any,
59650
+ * distributeChildUIState?: (groupState: any, childUIStateController: any) => any,
59651
+ * children?: import("ignore:preact").ComponentChildren,
59652
+ * [key: string]: any,
59653
+ * }>}
59654
+ * Everything a box takes is taken here too — `width`, `borderWidth`,
59655
+ * `borderRadius`, `backgroundColor`: the frame is the group's, and its corners
59656
+ * are passed on to the spins sitting in them.
59657
+ */
59658
+ const SpinGroup = props => {
59659
+ import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
59660
+ const {
59661
+ size
59662
+ } = props;
59663
+ const defaultRef = useRef(null);
59664
+ props.ref = props.ref || defaultRef;
59665
+ const groupRef = props.ref;
59666
+ // Two digit fields side by side are filled the way a date or a code is: the
59667
+ // hour reaching its two digits moves on to the minutes, and Left/Right at
59668
+ // either end of a field walk between them.
59669
+ useInputGroup(groupRef);
59670
+ const [controlgroupRootProps, controlgroupProps, childrenWrapperProps] = useControlgroupProps(props, {
59671
+ allowCapture: true,
59672
+ wantRequesterButtonState: true,
59673
+ controlType: "control_group",
59674
+ stateType: "object",
59675
+ cascadeValidationToChildren: true,
59676
+ aggregateChildStates: props.aggregateChildStates,
59677
+ distributeChildUIState: props.distributeChildUIState
59678
+ });
59679
+ const {
59680
+ children
59681
+ } = controlgroupProps;
59682
+ const {
59683
+ readOnly,
59684
+ disabled,
59685
+ loading
59686
+ } = childrenWrapperProps;
59687
+ return jsxs(Box, {
59688
+ ...controlgroupRootProps,
59689
+ ...controlgroupProps,
59690
+ // Consumed by the group hook above; blanked after the spreads so they do
59691
+ // not reach the DOM as unknown attributes.
59692
+ aggregateChildStates: undefined,
59693
+ distributeChildUIState: undefined,
59694
+ baseClassName: "navi_spin_group",
59695
+ pseudoClasses: SPIN_GROUP_PSEUDO_CLASSES
59696
+ // What the frame and what sits between the spins are drawn from: the
59697
+ // spins fade themselves, and the group is what holds those two.
59698
+ ,
59699
+
59700
+ "data-readonly": readOnly ? "" : undefined,
59701
+ "data-disabled": disabled ? "" : undefined,
59702
+ "data-loading": loading ? "" : undefined,
59703
+ children: [jsx(LoadingOutline, {
59704
+ loading: loading,
59705
+ color: "var(--navi-loader-color)",
59706
+ inset: -2
59707
+ }), jsx(SpinGroupContext.Provider, {
59708
+ value: {
59709
+ size
59710
+ },
59711
+ children: jsx(ControlgroupChildrenWrapper, {
59712
+ ...childrenWrapperProps,
59713
+ // The group's name says where its value lands in the form; each spin
59714
+ // inside is named on its own.
59715
+ name: undefined,
59716
+ children: children
59717
+ })
59718
+ })]
59719
+ });
59720
+ };
59721
+
59722
+ // Lets a group hand down what is said once for all the spins in it (how big
59723
+ // they are written), and lets a spin know it is in one at all — which is what
59724
+ // moves the frame and the focus ring off the spin and onto the group.
59725
+ const SpinGroupContext = createContext(null);
59726
+ const SPIN_GROUP_PSEUDO_CLASSES = [":focus-within",
59727
+ // Nothing focuses the group for real — a spin inside it takes the keyboard —
59728
+ // but it is where the ring is drawn, so a demo can hold it there.
59729
+ ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
59730
+
59731
+ /**
59732
+ * SpinGroup.Separator — what is written between two spins ("h", ":", a word).
59733
+ * It stands as tall as they do, so what it says lands on their line.
59734
+ */
59735
+ const SpinGroupSeparator = ({
59736
+ children,
59737
+ ...rest
59738
+ }) => jsx(Box, {
59739
+ as: "span",
59740
+ ...rest,
59741
+ className: "navi_spin_group_separator",
59742
+ "aria-hidden": "true",
59743
+ children: children
59744
+ });
59745
+ SpinGroup.Separator = SpinGroupSeparator;
59746
+
58919
59747
  /**
58920
59748
  * A whole number one steps through and types into: the field IS the middle, so
58921
59749
  * the value can be typed as readily as stepped, and the two chevrons stand
@@ -58928,16 +59756,26 @@ const renderValueDefault = value => String(value ?? "");
58928
59756
  * min?: number,
58929
59757
  * max?: number,
58930
59758
  * step?: number,
59759
+ * pad?: number,
59760
+ * loop?: boolean,
58931
59761
  * [key: string]: any,
58932
59762
  * }>}
58933
59763
  * @param {number} [min=0] The lowest number one can reach; `max` is the
58934
59764
  * highest. They also bound what typing can produce, and how wide the field
58935
59765
  * is asked to be (see `maxLength`).
59766
+ * @param {boolean} [loop] The numbers go round: the step after `max` is `min`,
59767
+ * and the one before `min` is `max`. Both ends have to be known for that.
59768
+ * @param {number} [pad] How many digits the number is written on, zeroes in
59769
+ * front of it: `pad={2}` writes 0 as "00". What an hour, a minute or a second
59770
+ * is read as — a clock says "07:00", never "7:0". Only the way it is written:
59771
+ * what the control holds, and what a form carries, is the number itself.
58936
59772
  */
58937
59773
  const NumberSpin = ({
58938
59774
  min = 0,
58939
59775
  max,
58940
59776
  step = 1,
59777
+ pad,
59778
+ loop,
58941
59779
  vertical = true,
58942
59780
  growsUpward = true,
58943
59781
  controlProps,
@@ -58951,29 +59789,64 @@ const NumberSpin = ({
58951
59789
  step: step,
58952
59790
  vertical: vertical,
58953
59791
  fallbackValue: min,
58954
- valueAtStep: (value, count) => numberAtStep(value, count, min),
59792
+ valueAtStep: (value, count) => numberAtStep(value, count, {
59793
+ min,
59794
+ max,
59795
+ loop
59796
+ }),
58955
59797
  compareValues: (a, b) => Number(a) - Number(b),
58956
59798
  controlProps: {
58957
59799
  // The numeric keypad on a phone, and — through
58958
59800
  // input_resolver_mode — the "this field is full" event a group of
58959
59801
  // fields moves along on (see useInputGroup).
58960
- inputMode: "numeric",
58961
- maxLength: max === undefined ? undefined : String(max).length,
59802
+ "inputMode": "numeric",
59803
+ "maxLength": numberMaxLength(max, pad),
59804
+ // What the number is HELD as stays a number; `pad` is how it is written
59805
+ // in the field (see asControlHostValue).
59806
+ "navi-value-pad": pad,
58962
59807
  ...controlProps
58963
59808
  },
58964
59809
  ...rest
58965
59810
  });
58966
59811
 
59812
+ // How many characters the field takes: what the biggest number is worth, or
59813
+ // what the padding writes, whichever is longer.
59814
+ const numberMaxLength = (max, pad) => {
59815
+ if (max === undefined) {
59816
+ return pad;
59817
+ }
59818
+ const maxLength = String(max).length;
59819
+ if (pad && pad > maxLength) {
59820
+ return pad;
59821
+ }
59822
+ return maxLength;
59823
+ };
59824
+
58967
59825
  // One step away, bounds included: a step past `max` is a real number that
58968
59826
  // simply is not allowed, and Spin is the one that reads it as "nothing that
58969
59827
  // way" — clamping here would answer the chevron before it had a chance to say
58970
59828
  // so. A field mid-edit holding nothing (or nothing numeric) starts from `min`.
58971
- const numberAtStep = (value, count, min) => {
59829
+ //
59830
+ // Unless the numbers go round: the step after the last one is the first, so
59831
+ // the value handed back is always one Spin can reach and no chevron ever says
59832
+ // there is nothing that way. Going round needs both ends to be known — a
59833
+ // stretch open at one end has no other end to come back from.
59834
+ const numberAtStep = (value, count, {
59835
+ min,
59836
+ max,
59837
+ loop
59838
+ }) => {
58972
59839
  const number = Number(value);
58973
59840
  if (value === "" || value === undefined || Number.isNaN(number)) {
58974
59841
  return min;
58975
59842
  }
58976
- return number + count;
59843
+ const numberNext = number + count;
59844
+ if (!loop || max === undefined) {
59845
+ return numberNext;
59846
+ }
59847
+ const valueCount = max - min + 1;
59848
+ const offset = numberNext - min;
59849
+ return min + (offset % valueCount + valueCount) % valueCount;
58977
59850
  };
58978
59851
 
58979
59852
  /**
@@ -59107,6 +59980,212 @@ const addDays = (day, count) => {
59107
59980
  return dateToDay(date);
59108
59981
  };
59109
59982
 
59983
+ /**
59984
+ * A time of day, and a span between two of them, written the way a clock
59985
+ * writes them: two digits, an "h" (a ":" in English) between hours and
59986
+ * minutes, and one frame around the whole thing — "07h00" is one value, not a
59987
+ * 7 beside a 0.
59988
+ *
59989
+ * `TimeSpin` is a `SpinGroup` of two `NumberSpin`s: it takes and hands back a
59990
+ * single "HH:MM", so a form carries one field for it. `TimeRangeSpin` is two
59991
+ * of those, and it carries `{ start, end }` — with the one rule such a pair
59992
+ * always has, "the end comes after the start", checked when the form is sent
59993
+ * (see time_range_constraint.js).
59994
+ */
59995
+
59996
+ const HOUR_MAX = 23;
59997
+ const MINUTE_MAX = 59;
59998
+
59999
+ /**
60000
+ * @type {import("ignore:preact").FunctionComponent<{
60001
+ * name?: string,
60002
+ * value?: string,
60003
+ * defaultValue?: string,
60004
+ * signal?: import("@preact/signals").Signal<string>,
60005
+ * minuteStep?: number,
60006
+ * pad?: number,
60007
+ * loop?: boolean,
60008
+ * separator?: import("ignore:preact").ComponentChildren,
60009
+ * hourLabel?: string,
60010
+ * minuteLabel?: string,
60011
+ * [key: string]: any,
60012
+ * }>}
60013
+ * @param {string} [value] The time shown, as "HH:MM".
60014
+ * @param {number} [minuteStep=1] How many minutes a press on a minute chevron
60015
+ * covers — 15 for quarters of an hour.
60016
+ * @param {boolean} [loop=true] The hours and the minutes go round: 23h then 0h,
60017
+ * 59 minutes then 0. What a clock does — and there is no first or last hour
60018
+ * of a day to stop at. Say `loop={false}` for two ends one cannot step past.
60019
+ * @param {number} [pad=2] How many digits an hour and a minute are written on:
60020
+ * a clock says "07:00", never "7:0". Say `pad={0}` for the bare numbers.
60021
+ * @param {import("ignore:preact").ComponentChildren} [separator] What is written
60022
+ * between the hours and the minutes. "h" in French, ":" elsewhere.
60023
+ * Everything a box takes is taken here too — `width`, `borderRadius`, `size`.
60024
+ */
60025
+ const TimeSpin = ({
60026
+ minuteStep = 1,
60027
+ pad = 2,
60028
+ loop = true,
60029
+ separator = naviI18n("time.hour_separator"),
60030
+ hourLabel = naviI18n("time.hour_label"),
60031
+ minuteLabel = naviI18n("time.minute_label"),
60032
+ ...rest
60033
+ }) => jsxs(SpinGroup, {
60034
+ aggregateChildStates: aggregateTime,
60035
+ distributeChildUIState: distributeTime,
60036
+ ...rest,
60037
+ children: [jsx(NumberSpin, {
60038
+ name: "hour",
60039
+ min: 0,
60040
+ max: HOUR_MAX,
60041
+ pad: pad,
60042
+ loop: loop,
60043
+ controlProps: {
60044
+ "aria-label": hourLabel
60045
+ }
60046
+ }), jsx(SpinGroup.Separator, {
60047
+ children: separator
60048
+ }), jsx(NumberSpin, {
60049
+ name: "minute",
60050
+ min: 0,
60051
+ max: MINUTE_MAX,
60052
+ step: minuteStep,
60053
+ pad: pad,
60054
+ loop: loop,
60055
+ controlProps: {
60056
+ "aria-label": minuteLabel
60057
+ }
60058
+ })]
60059
+ });
60060
+
60061
+ // The two fields as one value, "HH:MM" — and nothing at all while one of them
60062
+ // is empty: half a time is not a time, and a form has nothing to send about it.
60063
+ const aggregateTime = childUIStateControllers => {
60064
+ let hour = "";
60065
+ let minute = "";
60066
+ for (const child of childUIStateControllers) {
60067
+ if (child.name === "hour") {
60068
+ hour = child.uiState ?? "";
60069
+ }
60070
+ if (child.name === "minute") {
60071
+ minute = child.uiState ?? "";
60072
+ }
60073
+ }
60074
+ if (hour === "" || minute === "") {
60075
+ return undefined;
60076
+ }
60077
+ return `${padTwo(hour)}:${padTwo(minute)}`;
60078
+ };
60079
+
60080
+ // The way back: what the group is set to (a picked value, a form being reset)
60081
+ // lands on the field it belongs to.
60082
+ const distributeTime = (groupState, childUIStateController) => {
60083
+ const parts = parseTime(groupState);
60084
+ if (!parts) {
60085
+ return undefined;
60086
+ }
60087
+ return parts[childUIStateController.name];
60088
+ };
60089
+ const parseTime = time => {
60090
+ if (typeof time !== "string") {
60091
+ return null;
60092
+ }
60093
+ const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
60094
+ if (!match) {
60095
+ return null;
60096
+ }
60097
+ // Numbers: what an hour and a minute are held as. How they are written —
60098
+ // "07" — is the field's business (see NumberSpin's `pad`).
60099
+ return {
60100
+ hour: Number(match[1]),
60101
+ minute: Number(match[2])
60102
+ };
60103
+ };
60104
+ const padTwo = value => String(value).padStart(2, "0");
60105
+
60106
+ /**
60107
+ * @type {import("ignore:preact").FunctionComponent<{
60108
+ * name?: string,
60109
+ * value?: { start?: string, end?: string },
60110
+ * defaultValue?: { start?: string, end?: string },
60111
+ * minuteStep?: number,
60112
+ * minDuration?: number,
60113
+ * pad?: number,
60114
+ * timeProps?: object,
60115
+ * loop?: boolean,
60116
+ * size?: string,
60117
+ * startLabel?: import("ignore:preact").ComponentChildren,
60118
+ * endLabel?: import("ignore:preact").ComponentChildren,
60119
+ * [key: string]: any,
60120
+ * }>}
60121
+ * @param {{ start?: string, end?: string }} [value] The span shown, as two
60122
+ * "HH:MM".
60123
+ * @param {import("ignore:preact").ComponentChildren} [startLabel] What is written
60124
+ * before the first time ("De"), and `endLabel` between the two ("à"). Say
60125
+ * `null` for neither.
60126
+ * @param {number} [minuteStep=1] How many minutes a press on a minute chevron
60127
+ * covers, on both times.
60128
+ * @param {object} [timeProps] Anything a `TimeSpin` takes, said once for both
60129
+ * of them — a radius, a width. `startTimeProps`/`endTimeProps` say it to one
60130
+ * of the two, and win over this one.
60131
+ * @param {number} [minDuration=0] How long the span must last at least, in
60132
+ * minutes. Zero by default: a span of no length is a span all the same, only
60133
+ * one that goes backwards is not. Checked when the form is sent, and the
60134
+ * answer is given on the end time.
60135
+ */
60136
+ const TimeRangeSpin = ({
60137
+ minuteStep = 1,
60138
+ minDuration = 0,
60139
+ pad = 2,
60140
+ loop = true,
60141
+ size,
60142
+ startLabel = naviI18n("time_range.from"),
60143
+ endLabel = naviI18n("time_range.to"),
60144
+ timeProps,
60145
+ startTimeProps,
60146
+ endTimeProps,
60147
+ ...rest
60148
+ }) => {
60149
+ const startId = useId();
60150
+ return jsxs(ControlGroup, {
60151
+ flex: true,
60152
+ alignY: "center",
60153
+ spacing: "s",
60154
+ size: size,
60155
+ ...rest,
60156
+ children: [startLabel === null ? null : jsx(Text, {
60157
+ size: size,
60158
+ children: startLabel
60159
+ }), jsx(TimeSpin, {
60160
+ id: startId,
60161
+ name: "start",
60162
+ minuteStep: minuteStep,
60163
+ pad: pad,
60164
+ loop: loop,
60165
+ size: size,
60166
+ ...timeProps,
60167
+ ...startTimeProps
60168
+ }), endLabel === null ? null : jsx(Text, {
60169
+ size: size,
60170
+ children: endLabel
60171
+ }), jsx(TimeSpin, {
60172
+ name: "end",
60173
+ minuteStep: minuteStep,
60174
+ pad: pad,
60175
+ loop: loop,
60176
+ size: size
60177
+ // Which time it comes after, and how much room there must be between
60178
+ // the two: said on the LATER of the two, so the answer is given where
60179
+ // the time one would have to move is (see time_range_constraint.js).
60180
+ ,
60181
+ "data-time-after": startId,
60182
+ "data-time-min-duration": minDuration,
60183
+ ...timeProps,
60184
+ ...endTimeProps
60185
+ })]
60186
+ });
60187
+ };
60188
+
59110
60189
  installImportMetaCssBuild(import.meta);// TOFIX: select in data then reset, it reset to red/blue instead of red/blue/green
59111
60190
  const css$p = /* css */`
59112
60191
  .navi_checkbox_group {
@@ -59527,228 +60606,6 @@ const formatIntlUnit = (unit, {
59527
60606
  }
59528
60607
  };
59529
60608
 
59530
- /**
59531
- * Wraps multiple inputs together and handles keyboard navigation and paste
59532
- * distribution between them.
59533
- *
59534
- * Keyboard navigation:
59535
- * ArrowRight at the end of an input moves focus to the next input.
59536
- * ArrowLeft at the start of an input moves focus to the previous input.
59537
- * navi_input_full (emitted when an input reaches maxLength) also moves forward.
59538
- *
59539
- * Paste distribution:
59540
- * When an input has a data-separator attribute, pasting a string that
59541
- * contains that separator (e.g. "27/04/1990" into a day input with
59542
- * data-separator="/") splits the text on each separator and fills the
59543
- * corresponding sub-inputs in order.
59544
- */
59545
- const useInputGroup = (ref) => {
59546
- const debugFocus = useDebugFocus();
59547
-
59548
- useEffect(() => {
59549
- const el = ref.current;
59550
- if (!el) {
59551
- return () => {};
59552
- }
59553
-
59554
- const getInputs = () =>
59555
- Array.from(el.querySelectorAll(".navi_control_input"));
59556
-
59557
- const focusInput = (input) => {
59558
- input.focus();
59559
- input.select();
59560
- };
59561
-
59562
- const handleKeyDown = (e) => {
59563
- if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") {
59564
- return;
59565
- }
59566
- const active = document.activeElement;
59567
- if (!isTextInputElement(active) || !el.contains(active)) {
59568
- return;
59569
- }
59570
- if (e.key === "ArrowRight") {
59571
- const allSelected =
59572
- active.selectionStart === 0 &&
59573
- active.selectionEnd === active.value.length;
59574
- const atEnd =
59575
- allSelected ||
59576
- (active.selectionStart === active.value.length &&
59577
- active.selectionEnd === active.value.length);
59578
- if (!atEnd) {
59579
- return;
59580
- }
59581
- const inputs = getInputs();
59582
- const idx = inputs.indexOf(active);
59583
- if (idx === -1) {
59584
- debugFocus(
59585
- e,
59586
- "InputGroup ArrowRight on non group input → do nothing",
59587
- );
59588
- return;
59589
- }
59590
- if (idx === inputs.length - 1) {
59591
- debugFocus(
59592
- e,
59593
- "InputGroup ArrowRight at end of last input → do nothing",
59594
- );
59595
- return;
59596
- }
59597
-
59598
- debugFocus(
59599
- e,
59600
- "InputGroup ArrowRight at end of input[%d] → focus input[%d]",
59601
- idx,
59602
- idx + 1,
59603
- );
59604
- e.preventDefault();
59605
- focusInput(inputs[idx + 1]);
59606
- return;
59607
- }
59608
- const allSelected =
59609
- active.selectionStart === 0 &&
59610
- active.selectionEnd === active.value.length;
59611
- const atStart =
59612
- allSelected ||
59613
- (active.selectionStart === 0 && active.selectionEnd === 0);
59614
- if (!atStart) {
59615
- return;
59616
- }
59617
- const inputs = getInputs();
59618
- const idx = inputs.indexOf(active);
59619
- if (idx === 0) {
59620
- return;
59621
- }
59622
- debugFocus(
59623
- e,
59624
- "InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
59625
- idx,
59626
- idx - 1,
59627
- );
59628
- e.preventDefault();
59629
- focusInput(inputs[idx - 1]);
59630
- };
59631
-
59632
- const handleNaviInputFull = (e) => {
59633
- if (!e.detail.event?.isTrusted) {
59634
- // Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
59635
- return;
59636
- }
59637
- const input = e.detail.event.currentTarget;
59638
- if (!el.contains(input)) {
59639
- return;
59640
- }
59641
- const inputs = getInputs();
59642
- const idx = inputs.indexOf(input);
59643
- if (idx === -1) {
59644
- return;
59645
- }
59646
- if (idx === inputs.length - 1) {
59647
- return;
59648
- }
59649
- const nextInput = inputs[idx + 1];
59650
- debugFocus(
59651
- e,
59652
- "InputGroup navi_input_full on input -> move to next input",
59653
- input,
59654
- nextInput,
59655
- );
59656
- e.preventDefault();
59657
- focusInput(nextInput);
59658
- };
59659
-
59660
- // const handlePaste = (e) => {
59661
- // const active = document.activeElement;
59662
- // if (!isTextInputElement(active) || !el.contains(active)) {
59663
- // return;
59664
- // }
59665
- // const inputs = getInputs();
59666
- // const startIdx = inputs.indexOf(active);
59667
- // if (startIdx === -1) {
59668
- // return;
59669
- // }
59670
- // const pastedText = e.clipboardData?.getData("text") ?? "";
59671
- // if (!pastedText) {
59672
- // return;
59673
- // }
59674
- // // Only intercept when the pasted text contains at least one separator
59675
- // // from the inputs starting at the focused position.
59676
- // const remainingInputs = inputs.slice(startIdx);
59677
- // const hasSeparatorMatch = remainingInputs.some(
59678
- // (input) =>
59679
- // input.dataset.separator &&
59680
- // pastedText.includes(input.dataset.separator),
59681
- // );
59682
- // if (!hasSeparatorMatch) {
59683
- // return;
59684
- // }
59685
- // e.preventDefault();
59686
- // let remaining = pastedText;
59687
- // let lastFilledIdx = startIdx;
59688
- // for (let i = 0; i < remainingInputs.length; i++) {
59689
- // const input = remainingInputs[i];
59690
- // const separator = input.dataset.separator;
59691
- // let part;
59692
- // if (separator && remaining.includes(separator)) {
59693
- // const sepIdx = remaining.indexOf(separator);
59694
- // part = remaining.slice(0, sepIdx);
59695
- // remaining = remaining.slice(sepIdx + separator.length);
59696
- // } else {
59697
- // part = remaining;
59698
- // remaining = "";
59699
- // }
59700
- // requestSubPaste(input, part, e);
59701
- // lastFilledIdx = startIdx + i;
59702
- // if (remaining === "") {
59703
- // break;
59704
- // }
59705
- // }
59706
- // focusInput(inputs[lastFilledIdx]);
59707
- // };
59708
-
59709
- el.addEventListener("keydown", handleKeyDown, { capture: true });
59710
- el.addEventListener("navi_input_full", handleNaviInputFull);
59711
- // el.addEventListener("paste", handlePaste, { capture: true });
59712
- return () => {
59713
- el.removeEventListener("keydown", handleKeyDown, { capture: true });
59714
- el.removeEventListener("navi_input_full", handleNaviInputFull);
59715
- // el.removeEventListener("paste", handlePaste, { capture: true });
59716
- };
59717
- }, [debugFocus]);
59718
- };
59719
-
59720
- // const requestSubPaste = (input, value, event) => {
59721
- // dispatchRequestInteraction(input, {
59722
- // event,
59723
- // name: "subpaste",
59724
- // allowed: () => {
59725
- // dispatchRequestSetUIState(input, value, { event });
59726
- // },
59727
- // });
59728
- // };
59729
-
59730
- const isTextInputElement = (el) => {
59731
- if (!el) {
59732
- return false;
59733
- }
59734
- if (el.tagName === "TEXTAREA") {
59735
- return true;
59736
- }
59737
- if (el.tagName !== "INPUT") {
59738
- return false;
59739
- }
59740
- const type = el.type || "text";
59741
- return (
59742
- type === "text" ||
59743
- type === "search" ||
59744
- type === "url" ||
59745
- type === "tel" ||
59746
- type === "email" ||
59747
- type === "password" ||
59748
- type === "number"
59749
- );
59750
- };
59751
-
59752
60609
  installImportMetaCssBuild(import.meta);const css$n = /* css */`
59753
60610
  .navi_input_duration {
59754
60611
  --duration-separator-spacing: 4px;
@@ -69134,5 +69991,5 @@ const UserSvg = () => jsx("svg", {
69134
69991
  })
69135
69992
  });
69136
69993
 
69137
- 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 };
69994
+ 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 };
69138
69995
  //# sourceMappingURL=jsenv_navi.js.map