@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.
- package/README.md +2 -0
- package/dist/jsenv_navi.js +1136 -279
- package/dist/jsenv_navi.js.map +78 -36
- package/dist/jsenv_navi_side_effects.js +1 -0
- package/dist/jsenv_navi_side_effects.js.map +2 -2
- package/docs/AI_INSTRUCTIONS.md +41 -6
- package/docs/control_group.md +4 -1
- package/docs/interactions.md +165 -14
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
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
|
|
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 `
|
|
12256
|
-
* same task thrown far and fast is gotten rid of.
|
|
12257
|
-
*
|
|
12258
|
-
*
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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,
|
|
12521
|
+
startDragTo(pointerDownEvent, effects, {
|
|
12360
12522
|
draggedElement: element,
|
|
12361
12523
|
// Nothing to land on when nothing reorders.
|
|
12362
|
-
itemSelector:
|
|
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 (
|
|
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
|
-
|
|
25017
|
-
|
|
25018
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25073
|
-
// group read-only so children don't appear interactive when they
|
|
25074
|
-
|
|
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
|
-
|
|
44887
|
-
if (maxLengthProp === -1) {
|
|
45185
|
+
let maxLength = input.maxLength;
|
|
45186
|
+
if (maxLength === -1) {
|
|
44888
45187
|
const naviMaxLengthAttr = input.getAttribute("navi-max-length");
|
|
44889
|
-
|
|
44890
|
-
// no max length
|
|
44891
|
-
return;
|
|
44892
|
-
}
|
|
44893
|
-
maxLength = Number(naviMaxLengthAttr);
|
|
45188
|
+
maxLength = naviMaxLengthAttr === null ? undefined : Number(naviMaxLengthAttr);
|
|
44894
45189
|
}
|
|
44895
|
-
|
|
45190
|
+
const caretAtEnd = input.selectionStart === input.value.length;
|
|
45191
|
+
if (!caretAtEnd) {
|
|
44896
45192
|
return;
|
|
44897
45193
|
}
|
|
44898
|
-
if (input
|
|
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
|
-
|
|
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
|
-
//
|
|
58693
|
-
//
|
|
58694
|
-
|
|
58695
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|