@jsenv/navi 0.29.342 → 0.29.344
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/dist/dev/jsenv_navi.js +4053 -3889
- package/dist/dev/jsenv_navi.js.map +14 -14
- package/dist/jsenv_navi.js +1691 -1527
- package/dist/jsenv_navi.js.map +14 -14
- package/docs/data_states.md +1 -1
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -12,7 +12,7 @@ import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, use
|
|
|
12
12
|
import { humanizeI18n, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
|
|
13
13
|
export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
|
|
14
14
|
import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
|
|
15
|
-
import { TYPE_RULE, durationContainsNaN, compareTwoDurations, durationToSeconds, DISPLAYABLE_RULE, MAX_LINE_BREAKS_RULE, NO_EMOJI_RULE, SINGLE_SPACE_RULE, createValidity, resolveCharClass, getCharClassMessageKey, compileCharClassAnchored, compileCharClass,
|
|
15
|
+
import { CHAR_CLASS_PRESETS, TYPE_RULE, durationContainsNaN, compareTwoDurations, durationToSeconds, DISPLAYABLE_RULE, MAX_LINE_BREAKS_RULE, NO_EMOJI_RULE, SINGLE_SPACE_RULE, createValidity, resolveCharClass, getCharClassMessageKey, compileCharClassAnchored, compileCharClass, parseDuration, durationToISOString } from "@jsenv/validity";
|
|
16
16
|
export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
|
|
17
17
|
import { Suspense, createPortal, forwardRef } from "preact/compat";
|
|
18
18
|
|
|
@@ -4578,565 +4578,1104 @@ const findControlRoot = (el) => {
|
|
|
4578
4578
|
return null;
|
|
4579
4579
|
};
|
|
4580
4580
|
|
|
4581
|
-
const dispatchRequestSetUIState = (element, value, detail) => {
|
|
4582
|
-
const controlHost = findControlHost(element) || element;
|
|
4583
|
-
return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
|
|
4584
|
-
...detail,
|
|
4585
|
-
value,
|
|
4586
|
-
});
|
|
4587
|
-
};
|
|
4588
|
-
const dispatchRequestClearUIState = (element, e) => {
|
|
4589
|
-
const controlHost = findControlHost(element) || element;
|
|
4590
|
-
return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
|
|
4591
|
-
event: e,
|
|
4592
|
-
});
|
|
4593
|
-
};
|
|
4594
|
-
const dispatchRequestResetUIState = (element, e) => {
|
|
4595
|
-
const controlHost = findControlHost(element) || element;
|
|
4596
|
-
return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
|
|
4597
|
-
event: e,
|
|
4598
|
-
});
|
|
4599
|
-
};
|
|
4600
|
-
/**
|
|
4601
|
-
* @param {Element} el
|
|
4602
|
-
* @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
|
|
4603
|
-
* Only a button ever answers differently — one with no value of its own
|
|
4604
|
-
* inherits the value of the control around it, which is what makes
|
|
4605
|
-
* `--navi-send` on a form's button be about that form. Something asking what
|
|
4606
|
-
* THIS element says (a travel command reading what the travel is about) wants
|
|
4607
|
-
* the own value and would otherwise be handed the surrounding control's.
|
|
4608
|
-
*/
|
|
4609
|
-
const getUIStateFromElement = (el, { own } = {}) => {
|
|
4610
|
-
let uiState;
|
|
4611
|
-
dispatchInternalCustomEvent(el, "navi_get_ui_state", {
|
|
4612
|
-
own,
|
|
4613
|
-
respondWith: (v) => {
|
|
4614
|
-
uiState = v;
|
|
4615
|
-
},
|
|
4616
|
-
});
|
|
4617
|
-
return uiState;
|
|
4618
|
-
};
|
|
4619
|
-
|
|
4620
4581
|
/**
|
|
4621
|
-
*
|
|
4622
|
-
*
|
|
4623
|
-
*
|
|
4624
|
-
*
|
|
4625
|
-
*
|
|
4626
|
-
* - `number`/`range` inputs expect a numeric string or number
|
|
4627
|
-
* - `color` inputs require a non-empty hex string (falls back to `#000000`)
|
|
4628
|
-
* - All other inputs receive the value as-is (undefined → "")
|
|
4629
|
-
*
|
|
4630
|
-
* Returns either the converted value directly, or a converter function when the
|
|
4631
|
-
* conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
|
|
4632
|
-
*
|
|
4633
|
-
* @param {any} value - The JS value to convert.
|
|
4634
|
-
* @param {{ controlType: string, type: string }} options
|
|
4635
|
-
* @returns {any} The DOM-compatible value or a converter function.
|
|
4582
|
+
* Parses a time string into seconds.
|
|
4583
|
+
* Accepts:
|
|
4584
|
+
* - number: returned as-is (already in seconds)
|
|
4585
|
+
* - "HH:MM" string: converted to seconds (e.g. "00:30" → 1800, "01:00" → 3600)
|
|
4586
|
+
* - undefined/null: returned as-is
|
|
4636
4587
|
*/
|
|
4637
|
-
const
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
) => {
|
|
4641
|
-
if (controlType === "select") {
|
|
4642
|
-
// A select holds one of its options, always a string; holding nothing is
|
|
4643
|
-
// the empty option, which the element spells "".
|
|
4644
|
-
return asInputValue(jsValue);
|
|
4645
|
-
}
|
|
4646
|
-
if (controlType === "input" || controlType === "picker") {
|
|
4647
|
-
if (type === "datetime-local") {
|
|
4648
|
-
return asDatetimeLocalString(jsValue);
|
|
4649
|
-
}
|
|
4650
|
-
if (
|
|
4651
|
-
type === "number" ||
|
|
4652
|
-
type === "range" ||
|
|
4653
|
-
inputMode === "numeric" ||
|
|
4654
|
-
inputMode === "decimal"
|
|
4655
|
-
) {
|
|
4656
|
-
return asNumberString(jsValue, pad);
|
|
4657
|
-
}
|
|
4658
|
-
if (type === "color") {
|
|
4659
|
-
return asColorString(jsValue);
|
|
4660
|
-
}
|
|
4661
|
-
return asInputValue(jsValue);
|
|
4588
|
+
const timeStringToSeconds = (timeString) => {
|
|
4589
|
+
if (typeof timeString !== "string") {
|
|
4590
|
+
return timeString;
|
|
4662
4591
|
}
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
// datetime-local does not support timezones
|
|
4667
|
-
const asDatetimeLocalString = (dateTimeString) => {
|
|
4668
|
-
const date = new Date(dateTimeString);
|
|
4669
|
-
if (isNaN(date.getTime())) {
|
|
4670
|
-
return dateTimeString;
|
|
4592
|
+
const colonIndex = timeString.indexOf(":");
|
|
4593
|
+
if (colonIndex === -1) {
|
|
4594
|
+
return Number(timeString);
|
|
4671
4595
|
}
|
|
4672
|
-
const
|
|
4673
|
-
const
|
|
4674
|
-
|
|
4675
|
-
const hours = String(date.getHours()).padStart(2, "0");
|
|
4676
|
-
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
4677
|
-
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
4678
|
-
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
|
4596
|
+
const hours = parseInt(timeString.slice(0, colonIndex), 10);
|
|
4597
|
+
const minutes = parseInt(timeString.slice(colonIndex + 1), 10);
|
|
4598
|
+
return (hours * 60 + minutes) * 60;
|
|
4679
4599
|
};
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
const asNumberString = (jsValue, pad) => {
|
|
4685
|
-
if (jsValue === undefined) {
|
|
4686
|
-
return "";
|
|
4600
|
+
|
|
4601
|
+
const isToday = (value) => {
|
|
4602
|
+
if (!value) {
|
|
4603
|
+
return false;
|
|
4687
4604
|
}
|
|
4688
|
-
|
|
4689
|
-
|
|
4605
|
+
const now = new Date();
|
|
4606
|
+
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
4607
|
+
if (typeof value === "string") {
|
|
4608
|
+
return value === todayStr;
|
|
4690
4609
|
}
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4610
|
+
if (typeof value === "number") {
|
|
4611
|
+
const d = new Date(value);
|
|
4612
|
+
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
4613
|
+
return s === todayStr;
|
|
4694
4614
|
}
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
};
|
|
4699
|
-
// Browser requires a non-empty value for <input type="color">.
|
|
4700
|
-
// When our logical value is empty we give it #000000 so it doesn't choke.
|
|
4701
|
-
// The UI uses the original (possibly empty) value to show the checkerboard.
|
|
4702
|
-
const asColorString = (jsValue) => {
|
|
4703
|
-
return jsValue || "#000000";
|
|
4704
|
-
};
|
|
4705
|
-
const asInputValue = (jsValue) => {
|
|
4706
|
-
if (jsValue === undefined) {
|
|
4707
|
-
return "";
|
|
4615
|
+
if (value instanceof Date) {
|
|
4616
|
+
const s = `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
4617
|
+
return s === todayStr;
|
|
4708
4618
|
}
|
|
4709
|
-
return
|
|
4619
|
+
return false;
|
|
4710
4620
|
};
|
|
4711
4621
|
|
|
4712
4622
|
/**
|
|
4713
|
-
*
|
|
4623
|
+
* Returns the current time as "HH:MM", with an optional minute offset.
|
|
4714
4624
|
*
|
|
4715
|
-
*
|
|
4716
|
-
* -
|
|
4717
|
-
* - `<input type="number|range">` — parses as a number, returns `undefined` when empty
|
|
4718
|
-
* - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
|
|
4719
|
-
* via `navi_get_value` custom event (to preserve the original JS type of the value prop)
|
|
4720
|
-
* - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
|
|
4721
|
-
* - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
|
|
4722
|
-
* - All other inputs — returns `input.value` as a string
|
|
4625
|
+
* @param {number} [offsetMinutes=0] - Minutes to add (negative = subtract).
|
|
4626
|
+
* E.g. getNowHours(-5) returns "now minus 5 minutes".
|
|
4723
4627
|
*
|
|
4724
|
-
* @
|
|
4725
|
-
*
|
|
4628
|
+
* @example
|
|
4629
|
+
* getNowHours() // "14:30"
|
|
4630
|
+
* getNowHours(-5) // "14:25"
|
|
4726
4631
|
*/
|
|
4727
|
-
const
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
if (
|
|
4740
|
-
type === "number" ||
|
|
4741
|
-
type === "range" ||
|
|
4742
|
-
controlHost.inputMode === "numeric" ||
|
|
4743
|
-
controlHost.inputMode === "decimal"
|
|
4744
|
-
) {
|
|
4745
|
-
return readNumberFromInput(controlHost);
|
|
4746
|
-
}
|
|
4747
|
-
if (type === "color") {
|
|
4748
|
-
return readValueFromControlHost(controlHost);
|
|
4749
|
-
}
|
|
4750
|
-
if (type === "checkbox" || type === "radio") {
|
|
4751
|
-
return readValueFromCheckableInput(controlHost);
|
|
4752
|
-
}
|
|
4753
|
-
if (type === "datetime-local") {
|
|
4754
|
-
return readDatetimeLocalFromInput(controlHost);
|
|
4755
|
-
}
|
|
4756
|
-
if (type === "navi_js") {
|
|
4757
|
-
return getUIStateFromElement(controlHost);
|
|
4758
|
-
}
|
|
4759
|
-
return readValueFromInput(controlHost);
|
|
4760
|
-
}
|
|
4761
|
-
if (controlHost.hasAttribute("navi-control-host")) {
|
|
4762
|
-
// Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
|
|
4763
|
-
return readValueFromControlHost(controlHost);
|
|
4764
|
-
}
|
|
4765
|
-
return readValueFromElement(controlHost);
|
|
4766
|
-
};
|
|
4767
|
-
const readValueFromControlHost = (controlHost) => {
|
|
4768
|
-
return readValueFromNaviCustomEvent(controlHost, controlHost.value);
|
|
4769
|
-
};
|
|
4770
|
-
const readValueFromButton = (button) => {
|
|
4771
|
-
return readValueFromControlHost(button);
|
|
4632
|
+
const getNowHours = (offsetMinutes = 0) => {
|
|
4633
|
+
const now = new Date();
|
|
4634
|
+
const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
|
|
4635
|
+
const clamped =
|
|
4636
|
+
totalMinutes < 0
|
|
4637
|
+
? 0
|
|
4638
|
+
: totalMinutes > 23 * 60 + 59
|
|
4639
|
+
? 23 * 60 + 59
|
|
4640
|
+
: totalMinutes;
|
|
4641
|
+
const h = Math.floor(clamped / 60);
|
|
4642
|
+
const m = clamped % 60;
|
|
4643
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
4772
4644
|
};
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4645
|
+
|
|
4646
|
+
/**
|
|
4647
|
+
* Returns the current time rounded up to the nearest step boundary,
|
|
4648
|
+
* with an optional minute offset applied first.
|
|
4649
|
+
*
|
|
4650
|
+
* This is useful to compute a step-aligned `min` for a time picker:
|
|
4651
|
+
* passing it ensures the first available slot is always on a step boundary.
|
|
4652
|
+
*
|
|
4653
|
+
* @param {number} stepMinutes - Step size in minutes (e.g. 30).
|
|
4654
|
+
* @param {number} [offsetMinutes=0] - Minutes to add before rounding (negative = subtract).
|
|
4655
|
+
*
|
|
4656
|
+
* @example
|
|
4657
|
+
* // At 9:32, step 30, offset -5 → raw = 9:27 → ceil to 30 → "09:30"
|
|
4658
|
+
* // At 9:38, step 30, offset -5 → raw = 9:33 → ceil to 30 → "10:00"
|
|
4659
|
+
* getNowHoursRoundedToStep(30, -5)
|
|
4660
|
+
*/
|
|
4661
|
+
const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
|
|
4662
|
+
const now = new Date();
|
|
4663
|
+
const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
|
|
4664
|
+
const aligned = Math.ceil(totalMinutes / stepMinutes) * stepMinutes;
|
|
4665
|
+
const clamped =
|
|
4666
|
+
aligned < 0 ? 0 : aligned > 23 * 60 + 59 ? 23 * 60 + 59 : aligned;
|
|
4667
|
+
const h = Math.floor(clamped / 60);
|
|
4668
|
+
const m = clamped % 60;
|
|
4669
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
4783
4670
|
};
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4671
|
+
|
|
4672
|
+
/**
|
|
4673
|
+
* "HH:MM" and its two numbers, in both directions — what any control made of an
|
|
4674
|
+
* hour beside a minute (fields, wheels) aggregates to and is placed from. Held
|
|
4675
|
+
* as numbers, written on two digits: how they are shown is each control's own
|
|
4676
|
+
* business.
|
|
4677
|
+
*/
|
|
4678
|
+
const parseTimeParts = (time) => {
|
|
4679
|
+
if (typeof time !== "string") {
|
|
4680
|
+
return null;
|
|
4788
4681
|
}
|
|
4789
|
-
const
|
|
4790
|
-
if (
|
|
4791
|
-
return
|
|
4682
|
+
const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
|
|
4683
|
+
if (!match) {
|
|
4684
|
+
return null;
|
|
4792
4685
|
}
|
|
4793
|
-
return
|
|
4686
|
+
return { hour: Number(match[1]), minute: Number(match[2]) };
|
|
4794
4687
|
};
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4688
|
+
|
|
4689
|
+
// Half a time is not a time: a control holding one of the two and nothing in
|
|
4690
|
+
// the other has no value at all, and a form has nothing to send about it.
|
|
4691
|
+
const formatTimeParts = (hour, minute) => {
|
|
4692
|
+
if (
|
|
4693
|
+
hour === "" ||
|
|
4694
|
+
hour === undefined ||
|
|
4695
|
+
minute === "" ||
|
|
4696
|
+
minute === undefined
|
|
4697
|
+
) {
|
|
4798
4698
|
return undefined;
|
|
4799
4699
|
}
|
|
4800
|
-
return
|
|
4801
|
-
};
|
|
4802
|
-
const readValueFromInput = (input) => {
|
|
4803
|
-
const value = input.value;
|
|
4804
|
-
return value;
|
|
4805
|
-
};
|
|
4806
|
-
const readValueFromElement = (element) => {
|
|
4807
|
-
const value = element.value;
|
|
4808
|
-
return value;
|
|
4700
|
+
return `${padTwo$1(hour)}:${padTwo$1(minute)}`;
|
|
4809
4701
|
};
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
respondWith: (jsValue) => {
|
|
4816
|
-
responded = true;
|
|
4817
|
-
value = jsValue;
|
|
4818
|
-
},
|
|
4819
|
-
});
|
|
4820
|
-
if (responded) {
|
|
4821
|
-
return value;
|
|
4702
|
+
|
|
4703
|
+
const minutesFromTime$1 = (time) => {
|
|
4704
|
+
const parts = parseTimeParts(time);
|
|
4705
|
+
if (!parts) {
|
|
4706
|
+
return null;
|
|
4822
4707
|
}
|
|
4823
|
-
return
|
|
4708
|
+
return parts.hour * 60 + parts.minute;
|
|
4824
4709
|
};
|
|
4825
4710
|
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4711
|
+
const timeFromMinutes = (minutes) => {
|
|
4712
|
+
const inDay =
|
|
4713
|
+
((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
|
|
4714
|
+
return `${padTwo$1(Math.floor(inDay / 60))}:${padTwo$1(inDay % 60)}`;
|
|
4715
|
+
};
|
|
4830
4716
|
|
|
4831
|
-
|
|
4832
|
-
// Allows radio sibling unchecking without querying the DOM — necessary when
|
|
4833
|
-
// items are virtualized and their DOM element may not exist at the time.
|
|
4834
|
-
// Form scoping is reproduced by comparing parentUIStateController references.
|
|
4835
|
-
const radioControllersByName = new Map();
|
|
4717
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
4836
4718
|
|
|
4837
|
-
|
|
4838
|
-
// When a value is an object/array, we store it here and write a reference string to the DOM
|
|
4839
|
-
// instead of "[object Object]". Console-inspectable via window.__navi_js('id').
|
|
4840
|
-
// The controller id is used as key — if the controller has no id, the value is not registered.
|
|
4841
|
-
const naviJsRegistry = new Map();
|
|
4719
|
+
const padTwo$1 = (value) => String(value).padStart(2, "0");
|
|
4842
4720
|
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4721
|
+
// Maps validity type names → navi input type names.
|
|
4722
|
+
// Numeric signal types must not fall through to the native type="number"
|
|
4723
|
+
// (which adds spinner buttons and has poor UX) — they map to navi_number instead.
|
|
4724
|
+
const VALIDITY_TYPE_TO_INPUT_TYPE = {
|
|
4725
|
+
boolean: "checkbox",
|
|
4726
|
+
number: "navi_number",
|
|
4727
|
+
integer: "navi_number",
|
|
4728
|
+
percentage: "navi_percentage",
|
|
4847
4729
|
};
|
|
4848
4730
|
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
)
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
}
|
|
4862
|
-
|
|
4863
|
-
|
|
4731
|
+
// Conceptual navi types: defaults, plus the host type they resolve to.
|
|
4732
|
+
// `navi-input-type` keeps the type the caller asked for once the host has
|
|
4733
|
+
// become something plainer — it is what says the value is a number (see
|
|
4734
|
+
// isNumberInput), and what lets constraint messages use domain-specific
|
|
4735
|
+
// wording instead of the generic "Ce nombre doit être...".
|
|
4736
|
+
const NAVI_TYPE_DEFAULTS = {
|
|
4737
|
+
navi_time: {
|
|
4738
|
+
"type": "time",
|
|
4739
|
+
"navi-input-type": "time",
|
|
4740
|
+
"min": 0,
|
|
4741
|
+
"max": 24 * 3600 - 1,
|
|
4742
|
+
"step": 1,
|
|
4743
|
+
},
|
|
4744
|
+
navi_percentage: {
|
|
4745
|
+
"type": "navi_number",
|
|
4746
|
+
"navi-input-type": "percentage",
|
|
4747
|
+
"min": 0,
|
|
4748
|
+
"max": 100,
|
|
4749
|
+
"step": 1,
|
|
4750
|
+
},
|
|
4751
|
+
navi_number: {
|
|
4752
|
+
"type": "text",
|
|
4753
|
+
"navi-input-type": "number",
|
|
4754
|
+
"autoCorrect": "off",
|
|
4755
|
+
"spellcheck": false,
|
|
4756
|
+
"autoComplete": "off",
|
|
4757
|
+
},
|
|
4864
4758
|
};
|
|
4865
4759
|
|
|
4866
|
-
|
|
4867
|
-
const
|
|
4868
|
-
|
|
4760
|
+
// The navi input types whose value IS a number.
|
|
4761
|
+
const NUMBER_NAVI_INPUT_TYPE_SET = new Set([
|
|
4762
|
+
"number",
|
|
4763
|
+
"percentage",
|
|
4764
|
+
"hour",
|
|
4765
|
+
"minute",
|
|
4766
|
+
"second",
|
|
4767
|
+
]);
|
|
4768
|
+
|
|
4769
|
+
/**
|
|
4770
|
+
* Whether the control holds a number — asked of what the control IS, never of
|
|
4771
|
+
* `inputMode`. In HTML `inputmode` picks the on-screen keyboard and says
|
|
4772
|
+
* nothing about the value: a licence number, a postal code or a card number
|
|
4773
|
+
* with a check letter all want the digit keypad while staying strings. What a
|
|
4774
|
+
* number field is spelled `type="number"`, or a navi type that resolves to a
|
|
4775
|
+
* text host and leaves `navi-input-type` behind to say what it was.
|
|
4776
|
+
*/
|
|
4777
|
+
const isNumberInput = (type, naviInputType) => {
|
|
4778
|
+
if (type === "number") {
|
|
4869
4779
|
return true;
|
|
4870
4780
|
}
|
|
4871
|
-
|
|
4872
|
-
return type === "string" || type === "number" || type === "boolean";
|
|
4781
|
+
return NUMBER_NAVI_INPUT_TYPE_SET.has(naviInputType);
|
|
4873
4782
|
};
|
|
4874
4783
|
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4784
|
+
/**
|
|
4785
|
+
* resolveInputProps — normalizes input-related props that are shared across
|
|
4786
|
+
* `<Picker>`, `<Input>` (textual) and `<Range>`. Mutates the props object in place.
|
|
4787
|
+
*
|
|
4788
|
+
* Normalization is applied recursively: a navi type may resolve to another navi
|
|
4789
|
+
* type (e.g. `navi_percentage` → `navi_number` → `text`), and each step applies its
|
|
4790
|
+
* own formatters and defaults before moving to the next.
|
|
4791
|
+
*
|
|
4792
|
+
* Steps applied for each type:
|
|
4793
|
+
* 1. Record the original navi type in `props["navi-input-type"]` (first call only).
|
|
4794
|
+
* 2. Apply defaults for the current type (min, max, step, and any other props),
|
|
4795
|
+
* only when the prop is not already set.
|
|
4796
|
+
* 3. Apply min/max formatters (e.g. HH:MM string → number for duration types,
|
|
4797
|
+
* Date → formatted string for date/time types).
|
|
4798
|
+
* 4. Apply step formatter (same conversion rules).
|
|
4799
|
+
* 5. Remap `props.type` to the target type defined by the current type's defaults,
|
|
4800
|
+
* then recurse.
|
|
4801
|
+
*
|
|
4802
|
+
* Supported navi types and their targets:
|
|
4803
|
+
* - `navi_percentage` → `navi_number` (0–100, step 1)
|
|
4804
|
+
* - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
|
|
4805
|
+
* - `navi_time` → `time` (step in seconds)
|
|
4806
|
+
*
|
|
4807
|
+
* Standard HTML input types with formatters:
|
|
4808
|
+
* - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
|
|
4809
|
+
* min/max accept `Date` instances or timestamps and are converted to the
|
|
4810
|
+
* string format expected by the native input.
|
|
4811
|
+
* - `time`, `datetime-local`, `datetime`:
|
|
4812
|
+
* step accepts `"HH:MM"` and is converted to seconds.
|
|
4813
|
+
*/
|
|
4814
|
+
/**
|
|
4815
|
+
* A bound signal that carries a default of its own says the same thing on every
|
|
4816
|
+
* control: the control starts there and stays uncontrolled, which is what makes
|
|
4817
|
+
* a form read the value shown as a SUGGESTION rather than as something it
|
|
4818
|
+
* already holds. Uncontrolled here is about what the control HOLDS, not about
|
|
4819
|
+
* whether it follows the signal — the binding stays two-way either way (see
|
|
4820
|
+
* stateFromSignal in control_hooks.jsx). Written once and used by everything
|
|
4821
|
+
* that takes a `signal`, so one signal cannot mean two different things
|
|
4822
|
+
* depending on which control it was handed to.
|
|
4823
|
+
*/
|
|
4824
|
+
const seedDefaultValueFromSignal = (props) => {
|
|
4825
|
+
const signalOptions = props.signal?.options;
|
|
4826
|
+
if (!signalOptions) {
|
|
4827
|
+
return;
|
|
4914
4828
|
}
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
if (proxySet) {
|
|
4919
|
-
proxySet.delete(uiStateController);
|
|
4920
|
-
if (proxySet.size === 0) {
|
|
4921
|
-
proxyControllersByRealInputId.delete(proxyFor);
|
|
4922
|
-
}
|
|
4923
|
-
}
|
|
4829
|
+
if (Object.hasOwn(props, "defaultValue")) {
|
|
4830
|
+
// explicit defaultValue prop prevails
|
|
4831
|
+
return;
|
|
4924
4832
|
}
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
) {
|
|
4930
|
-
|
|
4931
|
-
if (set) {
|
|
4932
|
-
set.delete(uiStateController);
|
|
4933
|
-
if (set.size === 0) {
|
|
4934
|
-
radioControllersByName.delete(name);
|
|
4935
|
-
}
|
|
4936
|
-
}
|
|
4833
|
+
// Snapshot the signal's current default so that resetUIState restores to the
|
|
4834
|
+
// original default — not the value the signal had at the time of the last
|
|
4835
|
+
// re-render.
|
|
4836
|
+
const defaultValue = signalOptions.getDefaultValue(false);
|
|
4837
|
+
if (defaultValue !== undefined) {
|
|
4838
|
+
props.defaultValue = defaultValue;
|
|
4937
4839
|
}
|
|
4938
4840
|
};
|
|
4939
4841
|
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
const
|
|
4948
|
-
if (
|
|
4949
|
-
|
|
4842
|
+
const resolveInputProps = (props, { controlType = "input" } = {}) => {
|
|
4843
|
+
// `signal` carries a bound state signal. It is left on `props` on purpose:
|
|
4844
|
+
// `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
|
|
4845
|
+
// follow it, and `onUIAction` (ui_state_controller.js) writes user
|
|
4846
|
+
// interactions back into it. Here we only derive input defaults (type/min/max,
|
|
4847
|
+
// defaultValue/defaultChecked) from the signal's `options`, so the control
|
|
4848
|
+
// ends up uncontrolled-with-default while still bound to the signal.
|
|
4849
|
+
const signal = props.signal;
|
|
4850
|
+
if (signal) {
|
|
4851
|
+
const signalOptions = signal.options;
|
|
4852
|
+
if (signalOptions) {
|
|
4853
|
+
for (const key of ["min", "max", "step"]) {
|
|
4854
|
+
if (props[key] === undefined && signalOptions[key] !== undefined) {
|
|
4855
|
+
props[key] = signalOptions[key];
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
if (props.type === undefined && signalOptions.type !== undefined) {
|
|
4859
|
+
const typeFromSignal =
|
|
4860
|
+
VALIDITY_TYPE_TO_INPUT_TYPE[signalOptions.type] ?? signalOptions.type;
|
|
4861
|
+
// What a signal says is what its value IS; what a control's `type` says
|
|
4862
|
+
// is what the control is. They usually agree — a date-typed signal wants
|
|
4863
|
+
// a date field — but a boolean one maps to a checkbox, and a picker made
|
|
4864
|
+
// into a checkbox is not a picker with a different look: it is another
|
|
4865
|
+
// control, with no popup to open. A picker asked to hold a yes/no keeps
|
|
4866
|
+
// its two rows and stays itself.
|
|
4867
|
+
const wouldChangeWhatTheControlIs =
|
|
4868
|
+
controlType === "picker" &&
|
|
4869
|
+
(typeFromSignal === "checkbox" || typeFromSignal === "radio");
|
|
4870
|
+
if (!wouldChangeWhatTheControlIs) {
|
|
4871
|
+
props.type = typeFromSignal;
|
|
4872
|
+
}
|
|
4873
|
+
}
|
|
4874
|
+
}
|
|
4875
|
+
|
|
4876
|
+
const isCheckable = props.type === "checkbox" || props.type === "radio";
|
|
4877
|
+
if (isCheckable) {
|
|
4878
|
+
if (Object.hasOwn(props, "defaultChecked")) ; else {
|
|
4879
|
+
// If no explicit defaultChecked, derive it from the signal's default
|
|
4880
|
+
// value so that resetUIState restores to the original default.
|
|
4881
|
+
// Only a stateSignal carries a default of its own; a plain signal has
|
|
4882
|
+
// no `options` at all, and asking it for one used to throw on mount —
|
|
4883
|
+
// the same optional read every other branch here already does.
|
|
4884
|
+
const defaultVal = signalOptions?.getDefaultValue(false);
|
|
4885
|
+
if (defaultVal === undefined) ; else if (props.type === "radio") {
|
|
4886
|
+
if (defaultVal === true) {
|
|
4887
|
+
props.defaultChecked = true;
|
|
4888
|
+
} else if (
|
|
4889
|
+
Object.hasOwn(props, "value") &&
|
|
4890
|
+
defaultVal === props.value
|
|
4891
|
+
) {
|
|
4892
|
+
props.defaultChecked = true;
|
|
4893
|
+
}
|
|
4894
|
+
} else if (typeof defaultVal === "boolean") {
|
|
4895
|
+
// Standalone checkbox bound to a boolean signal.
|
|
4896
|
+
props.defaultChecked = defaultVal;
|
|
4897
|
+
} else {
|
|
4898
|
+
// Checkbox is a group member: defaultVal is the array of
|
|
4899
|
+
// selected item values.
|
|
4900
|
+
const checkboxValue = props.value;
|
|
4901
|
+
props.defaultChecked =
|
|
4902
|
+
Array.isArray(defaultVal) && defaultVal.includes(checkboxValue);
|
|
4903
|
+
}
|
|
4904
|
+
}
|
|
4905
|
+
return;
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4908
|
+
seedDefaultValueFromSignal(props);
|
|
4950
4909
|
}
|
|
4951
|
-
return getUIStateControllerById(proxyFor) ?? null;
|
|
4952
|
-
};
|
|
4953
4910
|
|
|
4954
|
-
|
|
4955
|
-
//
|
|
4956
|
-
//
|
|
4957
|
-
|
|
4958
|
-
const
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4911
|
+
const currentType = props.type;
|
|
4912
|
+
// Apply min/max/step formatters before anything else — this must run even for
|
|
4913
|
+
// standard HTML types (date, time, etc.) that have no NAVI_TYPE_DEFAULTS entry.
|
|
4914
|
+
const currentTypeMinMaxFormatter = MIN_MAX_FORMATTER_BY_TYPE[currentType];
|
|
4915
|
+
const currentTypeStepFormatter = STEP_FORMATTER_BY_TYPE[currentType];
|
|
4916
|
+
if (currentTypeMinMaxFormatter) {
|
|
4917
|
+
props.min = currentTypeMinMaxFormatter(props.min);
|
|
4918
|
+
props.max = currentTypeMinMaxFormatter(props.max);
|
|
4919
|
+
}
|
|
4920
|
+
if (currentTypeStepFormatter) {
|
|
4921
|
+
props.step = currentTypeStepFormatter(props.step);
|
|
4962
4922
|
}
|
|
4963
|
-
return proxyControllersByRealInputId.get(realInputId) ?? null;
|
|
4964
|
-
};
|
|
4965
4923
|
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4924
|
+
// For navi_number: choose inputMode based on whether step/min/max suggest decimals.
|
|
4925
|
+
// inputMode="numeric" (integer keyboard) vs "decimal" (keyboard with decimal separator).
|
|
4926
|
+
if (currentType === "navi_number") {
|
|
4927
|
+
if (props.inputMode === undefined) {
|
|
4928
|
+
props.inputMode =
|
|
4929
|
+
hasDecimalPlaces(props.step) ||
|
|
4930
|
+
hasDecimalPlaces(props.min) ||
|
|
4931
|
+
hasDecimalPlaces(props.max)
|
|
4932
|
+
? "decimal"
|
|
4933
|
+
: "numeric";
|
|
4934
|
+
}
|
|
4935
|
+
}
|
|
4974
4936
|
|
|
4975
|
-
const
|
|
4937
|
+
const { charGuard } = props;
|
|
4938
|
+
if (charGuard) {
|
|
4939
|
+
if (charGuard === true || charGuard === "auto") {
|
|
4940
|
+
// Auto-resolve charGuard from context.
|
|
4941
|
+
let charGuardResolved;
|
|
4942
|
+
const inputMode = props.inputMode;
|
|
4943
|
+
if (inputMode === "numeric") {
|
|
4944
|
+
charGuardResolved = "numeric";
|
|
4945
|
+
} else if (inputMode === "decimal") {
|
|
4946
|
+
charGuardResolved = "decimal";
|
|
4947
|
+
} else if (currentType === "tel") {
|
|
4948
|
+
charGuardResolved = "tel";
|
|
4949
|
+
} else if (currentType === "email") {
|
|
4950
|
+
charGuardResolved = "email";
|
|
4951
|
+
}
|
|
4952
|
+
if (charGuardResolved !== undefined) {
|
|
4953
|
+
props.charGuard = charGuardResolved;
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
// charGuard is now resolved: derive inputMode from it if not already set.
|
|
4957
|
+
if (props.inputMode === undefined && props.charGuard) {
|
|
4958
|
+
const autoMode = INPUT_MODE_FROM_CHAR_GUARD[props.charGuard];
|
|
4959
|
+
if (autoMode) {
|
|
4960
|
+
props.inputMode = autoMode;
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
// Build pattern from the resolved charGuard (preset name → class, or raw class passthrough).
|
|
4964
|
+
if (props.pattern === undefined && props.charGuard) {
|
|
4965
|
+
const charClass = CHAR_CLASS_PRESETS[props.charGuard] ?? props.charGuard;
|
|
4966
|
+
props.pattern = `${charClass}*`;
|
|
4967
|
+
}
|
|
4968
|
+
}
|
|
4976
4969
|
|
|
4977
|
-
|
|
4978
|
-
//
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
const
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4970
|
+
// Compute maxLength from max when inputMode is numeric/decimal.
|
|
4971
|
+
// Done here (after inputMode is set) so controller.props has the resolved value.
|
|
4972
|
+
if (props.maxLength === undefined) {
|
|
4973
|
+
if (props.inputMode === "numeric") {
|
|
4974
|
+
const { min, max } = props;
|
|
4975
|
+
if (max === undefined) ; else {
|
|
4976
|
+
const canBeNegative = min === undefined ? max < 0 : min < 0;
|
|
4977
|
+
const signCharCount = canBeNegative ? 1 : 0;
|
|
4978
|
+
const integerDigitCount = String(Math.floor(Math.abs(max))).length;
|
|
4979
|
+
props.maxLength = signCharCount + integerDigitCount;
|
|
4980
|
+
}
|
|
4981
|
+
} else if (props.inputMode === "decimal") {
|
|
4982
|
+
const { min, max, step } = props;
|
|
4983
|
+
if (max === undefined) ; else if (step === undefined) ; else {
|
|
4984
|
+
const canBeNegative = min === undefined ? max < 0 : min < 0;
|
|
4985
|
+
const signCharCount = canBeNegative ? 1 : 0;
|
|
4986
|
+
const integerDigitCount = String(Math.floor(Math.abs(max))).length;
|
|
4987
|
+
const stepStr = String(step);
|
|
4988
|
+
const dotIndex = stepStr.indexOf(".");
|
|
4989
|
+
// integer step + decimal inputMode is an unusual combo, but we stay consistent:
|
|
4990
|
+
// no decimal part in maxLength since valid values are whole numbers anyway
|
|
4991
|
+
const isIntegerStep = dotIndex === -1;
|
|
4992
|
+
const decimalSignCharCount = isIntegerStep ? 0 : 1;
|
|
4993
|
+
const decimalDigitCount = isIntegerStep
|
|
4994
|
+
? 0
|
|
4995
|
+
: stepStr.length - dotIndex - 1;
|
|
4996
|
+
props.maxLength =
|
|
4997
|
+
signCharCount +
|
|
4998
|
+
integerDigitCount +
|
|
4999
|
+
decimalSignCharCount +
|
|
5000
|
+
decimalDigitCount;
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
4989
5003
|
}
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
5004
|
+
|
|
5005
|
+
// Resolve maxLengthGuard boolean/auto → the computed maxLength number.
|
|
5006
|
+
if (props.maxLengthGuard === true || props.maxLengthGuard === "auto") {
|
|
5007
|
+
props.maxLengthGuard =
|
|
5008
|
+
typeof props.maxLength === "number" ? props.maxLength : undefined;
|
|
4993
5009
|
}
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5010
|
+
|
|
5011
|
+
const currentTypeDefaults = NAVI_TYPE_DEFAULTS[currentType];
|
|
5012
|
+
if (!currentTypeDefaults) {
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
for (const key of Object.keys(currentTypeDefaults)) {
|
|
5017
|
+
if (props[key] === undefined) {
|
|
5018
|
+
props[key] = currentTypeDefaults[key];
|
|
5001
5019
|
}
|
|
5002
5020
|
}
|
|
5003
|
-
|
|
5004
|
-
|
|
5021
|
+
const targetType = currentTypeDefaults.type;
|
|
5022
|
+
props.type = targetType;
|
|
5023
|
+
resolveInputProps(props);
|
|
5005
5024
|
};
|
|
5006
5025
|
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5026
|
+
// Presets that imply a specific mobile keyboard inputMode.
|
|
5027
|
+
const INPUT_MODE_FROM_CHAR_GUARD = {
|
|
5028
|
+
numeric: "numeric",
|
|
5029
|
+
pin: "numeric",
|
|
5030
|
+
card: "numeric",
|
|
5031
|
+
tel: "tel",
|
|
5032
|
+
decimal: "decimal",
|
|
5033
|
+
};
|
|
5014
5034
|
|
|
5015
|
-
const
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
displayable: "displayableMessage",
|
|
5027
|
-
max_line_breaks: "maxLineBreaksMessage",
|
|
5028
|
-
no_emoji: "noEmojiMessage",
|
|
5029
|
-
same_as: "sameAsMessage",
|
|
5030
|
-
min_lower_letter: "minLowerLetterMessage",
|
|
5031
|
-
min_upper_letter: "minUpperLetterMessage",
|
|
5032
|
-
min_digit: "minDigitMessage",
|
|
5033
|
-
min_special_char: "minSpecialCharMessage",
|
|
5034
|
-
one_of: "oneOfMessage",
|
|
5035
|
-
readonly: "readOnlyMessage",
|
|
5036
|
-
busy: "busyMessage",
|
|
5037
|
-
available: "availableMessage",
|
|
5035
|
+
const normalizeToDate = (value) => {
|
|
5036
|
+
if (value === undefined || value === null) {
|
|
5037
|
+
return null;
|
|
5038
|
+
}
|
|
5039
|
+
if (typeof value === "number") {
|
|
5040
|
+
return new Date(value);
|
|
5041
|
+
}
|
|
5042
|
+
if (value instanceof Date) {
|
|
5043
|
+
return value;
|
|
5044
|
+
}
|
|
5045
|
+
return null;
|
|
5038
5046
|
};
|
|
5039
5047
|
|
|
5040
|
-
const
|
|
5041
|
-
|
|
5042
|
-
)
|
|
5048
|
+
const toInputDate = (value) => {
|
|
5049
|
+
const date = normalizeToDate(value);
|
|
5050
|
+
if (!date) {
|
|
5051
|
+
return value;
|
|
5052
|
+
}
|
|
5053
|
+
const yyyy = date.getFullYear();
|
|
5054
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
5055
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
5056
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
5057
|
+
};
|
|
5058
|
+
const toInputMonth = (value) => {
|
|
5059
|
+
const date = normalizeToDate(value);
|
|
5060
|
+
if (!date) {
|
|
5061
|
+
return value;
|
|
5062
|
+
}
|
|
5063
|
+
const yyyy = date.getFullYear();
|
|
5064
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
5065
|
+
return `${yyyy}-${mm}`;
|
|
5066
|
+
};
|
|
5067
|
+
const toInputWeek = (value) => {
|
|
5068
|
+
const date = normalizeToDate(value);
|
|
5069
|
+
if (!date) {
|
|
5070
|
+
return value;
|
|
5071
|
+
}
|
|
5072
|
+
// ISO week number
|
|
5073
|
+
const d = new Date(date);
|
|
5074
|
+
d.setHours(0, 0, 0, 0);
|
|
5075
|
+
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
|
5076
|
+
const yearStart = new Date(d.getFullYear(), 0, 4);
|
|
5077
|
+
const week =
|
|
5078
|
+
Math.round(
|
|
5079
|
+
((d - yearStart) / 86400000 - 3 + ((yearStart.getDay() + 6) % 7)) / 7,
|
|
5080
|
+
) + 1;
|
|
5081
|
+
return `${d.getFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
5082
|
+
};
|
|
5083
|
+
const toInputTime = (value) => {
|
|
5084
|
+
const date = normalizeToDate(value);
|
|
5085
|
+
if (!date) {
|
|
5086
|
+
return value;
|
|
5087
|
+
}
|
|
5088
|
+
const hh = String(date.getHours()).padStart(2, "0");
|
|
5089
|
+
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
5090
|
+
return `${hh}:${mm}`;
|
|
5091
|
+
};
|
|
5092
|
+
const toInputDatetime = (value) => {
|
|
5093
|
+
const date = normalizeToDate(value);
|
|
5094
|
+
if (!date) {
|
|
5095
|
+
return value;
|
|
5096
|
+
}
|
|
5097
|
+
const yyyy = date.getFullYear();
|
|
5098
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
5099
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
5100
|
+
const hh = String(date.getHours()).padStart(2, "0");
|
|
5101
|
+
const min = String(date.getMinutes()).padStart(2, "0");
|
|
5102
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
|
|
5103
|
+
};
|
|
5043
5104
|
|
|
5044
|
-
const
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5105
|
+
const MIN_MAX_FORMATTER_BY_TYPE = {
|
|
5106
|
+
"date": toInputDate,
|
|
5107
|
+
"month": toInputMonth,
|
|
5108
|
+
"week": toInputWeek,
|
|
5109
|
+
"time": toInputTime,
|
|
5110
|
+
"datetime-local": toInputDatetime,
|
|
5111
|
+
"datetime": toInputDatetime,
|
|
5112
|
+
};
|
|
5113
|
+
const STEP_FORMATTER_BY_TYPE = {
|
|
5114
|
+
"time": timeStringToSeconds,
|
|
5115
|
+
"datetime-local": timeStringToSeconds,
|
|
5116
|
+
"datetime": timeStringToSeconds,
|
|
5117
|
+
};
|
|
5118
|
+
|
|
5119
|
+
const hasDecimalPlaces = (value) => {
|
|
5120
|
+
if (value === undefined || value === null) {
|
|
5121
|
+
return false;
|
|
5054
5122
|
}
|
|
5055
|
-
|
|
5123
|
+
const num = Number(value);
|
|
5124
|
+
return !isNaN(num) && !Number.isInteger(num);
|
|
5056
5125
|
};
|
|
5057
5126
|
|
|
5127
|
+
const dispatchRequestSetUIState = (element, value, detail) => {
|
|
5128
|
+
const controlHost = findControlHost(element) || element;
|
|
5129
|
+
return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
|
|
5130
|
+
...detail,
|
|
5131
|
+
value,
|
|
5132
|
+
});
|
|
5133
|
+
};
|
|
5134
|
+
const dispatchRequestClearUIState = (element, e) => {
|
|
5135
|
+
const controlHost = findControlHost(element) || element;
|
|
5136
|
+
return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
|
|
5137
|
+
event: e,
|
|
5138
|
+
});
|
|
5139
|
+
};
|
|
5140
|
+
const dispatchRequestResetUIState = (element, e) => {
|
|
5141
|
+
const controlHost = findControlHost(element) || element;
|
|
5142
|
+
return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
|
|
5143
|
+
event: e,
|
|
5144
|
+
});
|
|
5145
|
+
};
|
|
5058
5146
|
/**
|
|
5059
|
-
*
|
|
5060
|
-
*
|
|
5061
|
-
*
|
|
5062
|
-
*
|
|
5063
|
-
*
|
|
5064
|
-
*
|
|
5065
|
-
*
|
|
5066
|
-
* "[object Object]" on the screen.
|
|
5147
|
+
* @param {Element} el
|
|
5148
|
+
* @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
|
|
5149
|
+
* Only a button ever answers differently — one with no value of its own
|
|
5150
|
+
* inherits the value of the control around it, which is what makes
|
|
5151
|
+
* `--navi-send` on a form's button be about that form. Something asking what
|
|
5152
|
+
* THIS element says (a travel command reading what the travel is about) wants
|
|
5153
|
+
* the own value and would otherwise be handed the surrounding control's.
|
|
5067
5154
|
*/
|
|
5068
|
-
const
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5155
|
+
const getUIStateFromElement = (el, { own } = {}) => {
|
|
5156
|
+
let uiState;
|
|
5157
|
+
dispatchInternalCustomEvent(el, "navi_get_ui_state", {
|
|
5158
|
+
own,
|
|
5159
|
+
respondWith: (v) => {
|
|
5160
|
+
uiState = v;
|
|
5161
|
+
},
|
|
5162
|
+
});
|
|
5163
|
+
return uiState;
|
|
5076
5164
|
};
|
|
5077
5165
|
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5166
|
+
/**
|
|
5167
|
+
* Converts a JS value into the form expected by the browser DOM property for a
|
|
5168
|
+
* given control type/input type combination.
|
|
5169
|
+
*
|
|
5170
|
+
* For example:
|
|
5171
|
+
* - `datetime-local` inputs expect a local datetime string without timezone
|
|
5172
|
+
* - `number`/`range` inputs expect a numeric string or number
|
|
5173
|
+
* - `color` inputs require a non-empty hex string (falls back to `#000000`)
|
|
5174
|
+
* - All other inputs receive the value as-is (undefined → "")
|
|
5175
|
+
*
|
|
5176
|
+
* Returns either the converted value directly, or a converter function when the
|
|
5177
|
+
* conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
|
|
5178
|
+
*
|
|
5179
|
+
* @param {any} value - The JS value to convert.
|
|
5180
|
+
* @param {{ controlType: string, type: string }} options
|
|
5181
|
+
* @returns {any} The DOM-compatible value or a converter function.
|
|
5182
|
+
*/
|
|
5183
|
+
const asControlHostValue = (
|
|
5184
|
+
jsValue,
|
|
5185
|
+
{ controlType, type, naviInputType, pad },
|
|
5083
5186
|
) => {
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
if (
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
message: requesterControllerMessage,
|
|
5096
|
-
origin: "requester controller",
|
|
5097
|
-
};
|
|
5098
|
-
}
|
|
5187
|
+
if (controlType === "select") {
|
|
5188
|
+
// A select holds one of its options, always a string; holding nothing is
|
|
5189
|
+
// the empty option, which the element spells "".
|
|
5190
|
+
return asInputValue(jsValue);
|
|
5191
|
+
}
|
|
5192
|
+
if (controlType === "input" || controlType === "picker") {
|
|
5193
|
+
if (type === "datetime-local") {
|
|
5194
|
+
return asDatetimeLocalString(jsValue);
|
|
5195
|
+
}
|
|
5196
|
+
if (type === "range" || isNumberInput(type, naviInputType)) {
|
|
5197
|
+
return asNumberString(jsValue, pad);
|
|
5099
5198
|
}
|
|
5199
|
+
if (type === "color") {
|
|
5200
|
+
return asColorString(jsValue);
|
|
5201
|
+
}
|
|
5202
|
+
return asInputValue(jsValue);
|
|
5100
5203
|
}
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5204
|
+
return jsValue;
|
|
5205
|
+
};
|
|
5206
|
+
// As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
|
|
5207
|
+
// datetime-local does not support timezones
|
|
5208
|
+
const asDatetimeLocalString = (dateTimeString) => {
|
|
5209
|
+
const date = new Date(dateTimeString);
|
|
5210
|
+
if (isNaN(date.getTime())) {
|
|
5211
|
+
return dateTimeString;
|
|
5108
5212
|
}
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5213
|
+
const year = date.getFullYear();
|
|
5214
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
5215
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
5216
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
5217
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
5218
|
+
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
5219
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
|
5114
5220
|
};
|
|
5115
|
-
|
|
5116
|
-
//
|
|
5117
|
-
//
|
|
5118
|
-
//
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
"
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
"
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5221
|
+
// `pad` is how many digits the number is WRITTEN on — an hour is held as 7 and
|
|
5222
|
+
// shown as "07". Held and shown are two things here, the way they are for a
|
|
5223
|
+
// datetime-local above: what the field says is derived from what the control
|
|
5224
|
+
// holds, and reading it back (readNumberFromInput) gives the number again.
|
|
5225
|
+
const asNumberString = (jsValue, pad) => {
|
|
5226
|
+
if (jsValue === undefined) {
|
|
5227
|
+
return "";
|
|
5228
|
+
}
|
|
5229
|
+
if (!pad || jsValue === "" || jsValue === null) {
|
|
5230
|
+
return jsValue;
|
|
5231
|
+
}
|
|
5232
|
+
const number = Number(jsValue);
|
|
5233
|
+
if (Number.isNaN(number)) {
|
|
5234
|
+
return jsValue;
|
|
5235
|
+
}
|
|
5236
|
+
const negative = number < 0;
|
|
5237
|
+
const digits = String(negative ? -number : number).padStart(Number(pad), "0");
|
|
5238
|
+
return negative ? `-${digits}` : digits;
|
|
5239
|
+
};
|
|
5240
|
+
// Browser requires a non-empty value for <input type="color">.
|
|
5241
|
+
// When our logical value is empty we give it #000000 so it doesn't choke.
|
|
5242
|
+
// The UI uses the original (possibly empty) value to show the checkerboard.
|
|
5243
|
+
const asColorString = (jsValue) => {
|
|
5244
|
+
return jsValue || "#000000";
|
|
5245
|
+
};
|
|
5246
|
+
const asInputValue = (jsValue) => {
|
|
5247
|
+
if (jsValue === undefined) {
|
|
5248
|
+
return "";
|
|
5249
|
+
}
|
|
5250
|
+
return jsValue;
|
|
5251
|
+
};
|
|
5252
|
+
|
|
5253
|
+
/**
|
|
5254
|
+
* Reads the current logical JS value from a control host DOM element.
|
|
5255
|
+
*
|
|
5256
|
+
* Handles all navi control host element types:
|
|
5257
|
+
* - `<button>` — reads via `navi_get_value` custom event, falls back to `button.value`
|
|
5258
|
+
* - `<input type="number|range">` — parses as a number, returns `undefined` when empty
|
|
5259
|
+
* - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
|
|
5260
|
+
* via `navi_get_value` custom event (to preserve the original JS type of the value prop)
|
|
5261
|
+
* - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
|
|
5262
|
+
* - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
|
|
5263
|
+
* - All other inputs — returns `input.value` as a string
|
|
5264
|
+
*
|
|
5265
|
+
* @param {HTMLElement} controlHost - The control host DOM element to read from.
|
|
5266
|
+
* @returns {any} The current logical value of the control.
|
|
5267
|
+
*/
|
|
5268
|
+
const readControlValue = (controlHost) => {
|
|
5269
|
+
if (
|
|
5270
|
+
controlHost.tagName === "BUTTON" ||
|
|
5271
|
+
controlHost.getAttribute("role") === "button"
|
|
5272
|
+
) {
|
|
5273
|
+
return readValueFromButton(controlHost);
|
|
5274
|
+
}
|
|
5275
|
+
if (controlHost.tagName === "INPUT") {
|
|
5276
|
+
// important: input.type = "navi_js"; followed by input.type; returns "text"
|
|
5277
|
+
// so use getAttribute
|
|
5278
|
+
const type = controlHost.getAttribute("type");
|
|
5279
|
+
|
|
5280
|
+
if (
|
|
5281
|
+
type === "range" ||
|
|
5282
|
+
isNumberInput(type, controlHost.getAttribute("navi-input-type"))
|
|
5283
|
+
) {
|
|
5284
|
+
return readNumberFromInput(controlHost);
|
|
5285
|
+
}
|
|
5286
|
+
if (type === "color") {
|
|
5287
|
+
return readValueFromControlHost(controlHost);
|
|
5288
|
+
}
|
|
5289
|
+
if (type === "checkbox" || type === "radio") {
|
|
5290
|
+
return readValueFromCheckableInput(controlHost);
|
|
5291
|
+
}
|
|
5292
|
+
if (type === "datetime-local") {
|
|
5293
|
+
return readDatetimeLocalFromInput(controlHost);
|
|
5294
|
+
}
|
|
5295
|
+
if (type === "navi_js") {
|
|
5296
|
+
return getUIStateFromElement(controlHost);
|
|
5297
|
+
}
|
|
5298
|
+
return readValueFromInput(controlHost);
|
|
5299
|
+
}
|
|
5300
|
+
if (controlHost.hasAttribute("navi-control-host")) {
|
|
5301
|
+
// Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
|
|
5302
|
+
return readValueFromControlHost(controlHost);
|
|
5303
|
+
}
|
|
5304
|
+
return readValueFromElement(controlHost);
|
|
5305
|
+
};
|
|
5306
|
+
const readValueFromControlHost = (controlHost) => {
|
|
5307
|
+
return readValueFromNaviCustomEvent(controlHost, controlHost.value);
|
|
5308
|
+
};
|
|
5309
|
+
const readValueFromButton = (button) => {
|
|
5310
|
+
return readValueFromControlHost(button);
|
|
5311
|
+
};
|
|
5312
|
+
const readDatetimeLocalFromInput = (input) => {
|
|
5313
|
+
const localDateTimeString = input.value;
|
|
5314
|
+
if (localDateTimeString === "") {
|
|
5315
|
+
return "";
|
|
5316
|
+
}
|
|
5317
|
+
const localDate = new Date(localDateTimeString);
|
|
5318
|
+
if (isNaN(localDate.getTime())) {
|
|
5319
|
+
return localDateTimeString;
|
|
5320
|
+
}
|
|
5321
|
+
return localDate.toISOString();
|
|
5322
|
+
};
|
|
5323
|
+
const readNumberFromInput = (input) => {
|
|
5324
|
+
const numberString = input.value;
|
|
5325
|
+
if (numberString === "") {
|
|
5326
|
+
return "";
|
|
5327
|
+
}
|
|
5328
|
+
const asNumber = Number(numberString);
|
|
5329
|
+
if (isNaN(asNumber)) {
|
|
5330
|
+
return numberString;
|
|
5331
|
+
}
|
|
5332
|
+
return asNumber;
|
|
5333
|
+
};
|
|
5334
|
+
const readValueFromCheckableInput = (input) => {
|
|
5335
|
+
const checked = input.checked;
|
|
5336
|
+
if (!checked) {
|
|
5337
|
+
return undefined;
|
|
5338
|
+
}
|
|
5339
|
+
return readValueFromControlHost(input);
|
|
5340
|
+
};
|
|
5341
|
+
const readValueFromInput = (input) => {
|
|
5342
|
+
const value = input.value;
|
|
5343
|
+
return value;
|
|
5344
|
+
};
|
|
5345
|
+
const readValueFromElement = (element) => {
|
|
5346
|
+
const value = element.value;
|
|
5347
|
+
return value;
|
|
5348
|
+
};
|
|
5349
|
+
const readValueFromNaviCustomEvent = (field, fallback) => {
|
|
5350
|
+
// prefer the value given as prop (respect original type, browser would convert to string)
|
|
5351
|
+
let responded;
|
|
5352
|
+
let value;
|
|
5353
|
+
dispatchCustomEvent(field, "navi_get_value", {
|
|
5354
|
+
respondWith: (jsValue) => {
|
|
5355
|
+
responded = true;
|
|
5356
|
+
value = jsValue;
|
|
5357
|
+
},
|
|
5358
|
+
});
|
|
5359
|
+
if (responded) {
|
|
5360
|
+
return value;
|
|
5361
|
+
}
|
|
5362
|
+
return fallback;
|
|
5363
|
+
};
|
|
5364
|
+
|
|
5365
|
+
// In-memory registry of all mounted ui state controllers keyed by their id.
|
|
5366
|
+
// Allows direct controller access without dispatching DOM events — used by external
|
|
5367
|
+
// callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
|
|
5368
|
+
const controllersById = new Map();
|
|
5369
|
+
|
|
5370
|
+
// In-memory registry for radio controllers, keyed by input name.
|
|
5371
|
+
// Allows radio sibling unchecking without querying the DOM — necessary when
|
|
5372
|
+
// items are virtualized and their DOM element may not exist at the time.
|
|
5373
|
+
// Form scoping is reproduced by comparing parentUIStateController references.
|
|
5374
|
+
const radioControllersByName = new Map();
|
|
5375
|
+
|
|
5376
|
+
// Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
|
|
5377
|
+
// When a value is an object/array, we store it here and write a reference string to the DOM
|
|
5378
|
+
// instead of "[object Object]". Console-inspectable via window.__navi_js('id').
|
|
5379
|
+
// The controller id is used as key — if the controller has no id, the value is not registered.
|
|
5380
|
+
const naviJsRegistry = new Map();
|
|
5381
|
+
|
|
5382
|
+
const getUIStateControllerById = (id) => controllersById.get(id);
|
|
5383
|
+
const getRadioSiblings = (radioUIStateController) => {
|
|
5384
|
+
const siblings = radioControllersByName.get(radioUIStateController.name);
|
|
5385
|
+
return siblings;
|
|
5386
|
+
};
|
|
5387
|
+
|
|
5388
|
+
const toDomValue = (
|
|
5389
|
+
jsValue,
|
|
5390
|
+
{ controlType, id, type, naviInputType, pad },
|
|
5391
|
+
) => {
|
|
5392
|
+
const domValue = asControlHostValue(jsValue, {
|
|
5393
|
+
controlType,
|
|
5394
|
+
type,
|
|
5395
|
+
naviInputType,
|
|
5396
|
+
pad,
|
|
5397
|
+
});
|
|
5398
|
+
if (isSerializableAsDomValue(domValue)) {
|
|
5399
|
+
return domValue;
|
|
5400
|
+
}
|
|
5401
|
+
naviJsRegistry.set(id, domValue);
|
|
5402
|
+
return `window.__navi_js('${id}')`;
|
|
5403
|
+
};
|
|
5404
|
+
|
|
5405
|
+
window.__navi_js = (id) => naviJsRegistry.get(id);
|
|
5406
|
+
const isSerializableAsDomValue = (value) => {
|
|
5407
|
+
if (value === null || value === undefined) {
|
|
5408
|
+
return true;
|
|
5409
|
+
}
|
|
5410
|
+
const type = typeof value;
|
|
5411
|
+
return type === "string" || type === "number" || type === "boolean";
|
|
5412
|
+
};
|
|
5413
|
+
|
|
5414
|
+
const onUIStateControllerCreated = (uiStateController) => {
|
|
5415
|
+
const { id, name, controlType } = uiStateController;
|
|
5416
|
+
if (id) {
|
|
5417
|
+
controllersById.set(id, uiStateController);
|
|
5418
|
+
}
|
|
5419
|
+
const proxyFor = uiStateController.props["navi-control-proxy-for"];
|
|
5420
|
+
if (proxyFor) {
|
|
5421
|
+
let proxySet = proxyControllersByRealInputId.get(proxyFor);
|
|
5422
|
+
if (!proxySet) {
|
|
5423
|
+
proxySet = new Set();
|
|
5424
|
+
proxyControllersByRealInputId.set(proxyFor, proxySet);
|
|
5425
|
+
}
|
|
5426
|
+
proxySet.add(uiStateController);
|
|
5427
|
+
}
|
|
5428
|
+
if (
|
|
5429
|
+
controlType === "input" &&
|
|
5430
|
+
uiStateController.props.type === "radio" &&
|
|
5431
|
+
name
|
|
5432
|
+
) {
|
|
5433
|
+
let set = radioControllersByName.get(name);
|
|
5434
|
+
if (!set) {
|
|
5435
|
+
set = new Set();
|
|
5436
|
+
radioControllersByName.set(name, set);
|
|
5437
|
+
}
|
|
5438
|
+
set.add(uiStateController);
|
|
5439
|
+
}
|
|
5440
|
+
};
|
|
5441
|
+
const onUIStateControllerDestroyed = (uiStateController) => {
|
|
5442
|
+
const { id, name, controlType } = uiStateController;
|
|
5443
|
+
if (id) {
|
|
5444
|
+
// Only the controller the id currently points at may take the entry away:
|
|
5445
|
+
// when two controls share an id, the one leaving would otherwise unregister
|
|
5446
|
+
// the one staying (see the warning in onUIStateControllerCreated), and the
|
|
5447
|
+
// same holds while a control is being replaced by its successor, which
|
|
5448
|
+
// registers during its render, before this cleanup runs.
|
|
5449
|
+
if (controllersById.get(id) === uiStateController) {
|
|
5450
|
+
controllersById.delete(id);
|
|
5451
|
+
naviJsRegistry.delete(id);
|
|
5452
|
+
}
|
|
5453
|
+
}
|
|
5454
|
+
const proxyFor = uiStateController.props["navi-control-proxy-for"];
|
|
5455
|
+
if (proxyFor) {
|
|
5456
|
+
const proxySet = proxyControllersByRealInputId.get(proxyFor);
|
|
5457
|
+
if (proxySet) {
|
|
5458
|
+
proxySet.delete(uiStateController);
|
|
5459
|
+
if (proxySet.size === 0) {
|
|
5460
|
+
proxyControllersByRealInputId.delete(proxyFor);
|
|
5461
|
+
}
|
|
5462
|
+
}
|
|
5463
|
+
}
|
|
5464
|
+
if (
|
|
5465
|
+
controlType === "input" &&
|
|
5466
|
+
uiStateController.controlHostProps.type === "radio" &&
|
|
5467
|
+
name
|
|
5468
|
+
) {
|
|
5469
|
+
const set = radioControllersByName.get(name);
|
|
5470
|
+
if (set) {
|
|
5471
|
+
set.delete(uiStateController);
|
|
5472
|
+
if (set.size === 0) {
|
|
5473
|
+
radioControllersByName.delete(name);
|
|
5474
|
+
}
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
};
|
|
5478
|
+
|
|
5479
|
+
/**
|
|
5480
|
+
* Controller-based equivalent of findControlProxyTarget.
|
|
5481
|
+
* Given a proxy controller, returns the real control's controller.
|
|
5482
|
+
* Finds the target by walking the parent controller's children — no DOM queries.
|
|
5483
|
+
* Returns `null` when the controller is not a proxy or the target is not found.
|
|
5484
|
+
*/
|
|
5485
|
+
const findControlProxyTargetController = (controller) => {
|
|
5486
|
+
const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
|
|
5487
|
+
if (!proxyFor) {
|
|
5488
|
+
return null;
|
|
5489
|
+
}
|
|
5490
|
+
return getUIStateControllerById(proxyFor) ?? null;
|
|
5491
|
+
};
|
|
5492
|
+
|
|
5493
|
+
// Reverse-lookup map: real-input id → the proxy controllers that reference it
|
|
5494
|
+
// via `navi-control-proxy-for`. A single control can be represented by several
|
|
5495
|
+
// proxies (an "enable"/"disable" button pair for one radio, for instance), so
|
|
5496
|
+
// each id holds a set. Maintained on create/destroy so lookup is O(1).
|
|
5497
|
+
const proxyControllersByRealInputId = new Map();
|
|
5498
|
+
const findProxyControllers = (realInputId) => {
|
|
5499
|
+
if (!realInputId) {
|
|
5500
|
+
return null;
|
|
5501
|
+
}
|
|
5502
|
+
return proxyControllersByRealInputId.get(realInputId) ?? null;
|
|
5503
|
+
};
|
|
5504
|
+
|
|
5505
|
+
/**
|
|
5506
|
+
* The attributes constraints read, filled by each constraint module as it
|
|
5507
|
+
* evaluates. A constraint declares the attribute it wants (`"data-no-emoji"`)
|
|
5508
|
+
* and gets the prop for free: a control accepts the camelCase form
|
|
5509
|
+
* (`noEmoji`) and writes it on the control host under the attribute name — the
|
|
5510
|
+
* same conversion `element.dataset` does, so what a component is passed and
|
|
5511
|
+
* what ends up in the DOM read as one thing.
|
|
5512
|
+
*/
|
|
5513
|
+
|
|
5514
|
+
const CONSTRAINT_ATTRIBUTE_SET = new Set();
|
|
5515
|
+
|
|
5516
|
+
const dataAttributeCache = new Map();
|
|
5517
|
+
// A constraint imported lazily registers its attribute after controls have
|
|
5518
|
+
// already rendered, so an answer computed before it arrived must not survive it.
|
|
5519
|
+
let attributeCountWhenCached = 0;
|
|
5520
|
+
/**
|
|
5521
|
+
* The constraint attribute a prop stands for, `null` when it stands for none:
|
|
5522
|
+
* `"noEmoji"` → `"data-no-emoji"`.
|
|
5523
|
+
*/
|
|
5524
|
+
const constraintAttributeFromProp = (key) => {
|
|
5525
|
+
if (attributeCountWhenCached !== CONSTRAINT_ATTRIBUTE_SET.size) {
|
|
5526
|
+
dataAttributeCache.clear();
|
|
5527
|
+
attributeCountWhenCached = CONSTRAINT_ATTRIBUTE_SET.size;
|
|
5528
|
+
}
|
|
5529
|
+
const fromCache = dataAttributeCache.get(key);
|
|
5530
|
+
if (fromCache !== undefined) {
|
|
5531
|
+
return fromCache;
|
|
5532
|
+
}
|
|
5533
|
+
let attribute = null;
|
|
5534
|
+
// An attribute is already written as one (`data-no-emoji`, `aria-label`) —
|
|
5535
|
+
// there is nothing to convert, and the literal lookup has already happened.
|
|
5536
|
+
if (!key.includes("-")) {
|
|
5537
|
+
const candidate = `data-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
5538
|
+
if (CONSTRAINT_ATTRIBUTE_SET.has(candidate)) {
|
|
5539
|
+
attribute = candidate;
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
dataAttributeCache.set(key, attribute);
|
|
5543
|
+
return attribute;
|
|
5544
|
+
};
|
|
5545
|
+
|
|
5546
|
+
/**
|
|
5547
|
+
* Whether a constraint attribute is on. Present means on — `""` is how HTML
|
|
5548
|
+
* writes a bare attribute — and only the values that say "passed, and off"
|
|
5549
|
+
* turn it off.
|
|
5550
|
+
*/
|
|
5551
|
+
const isConstraintAttributeOn = (value) =>
|
|
5552
|
+
value !== undefined && value !== null && value !== false;
|
|
5553
|
+
|
|
5554
|
+
const CONSTRAINT_NAME_TO_PROP = {
|
|
5555
|
+
disabled: "disabledMessage",
|
|
5556
|
+
required: "requiredMessage",
|
|
5557
|
+
pattern: "patternMessage",
|
|
5558
|
+
type_email: "typeMessage",
|
|
5559
|
+
type_number: "typeMessage",
|
|
5560
|
+
min_length: "minLengthMessage",
|
|
5561
|
+
max_length: "maxLengthMessage",
|
|
5562
|
+
min: "minMessage",
|
|
5563
|
+
max: "maxMessage",
|
|
5564
|
+
single_space: "singleSpaceMessage",
|
|
5565
|
+
displayable: "displayableMessage",
|
|
5566
|
+
max_line_breaks: "maxLineBreaksMessage",
|
|
5567
|
+
no_emoji: "noEmojiMessage",
|
|
5568
|
+
same_as: "sameAsMessage",
|
|
5569
|
+
min_lower_letter: "minLowerLetterMessage",
|
|
5570
|
+
min_upper_letter: "minUpperLetterMessage",
|
|
5571
|
+
min_digit: "minDigitMessage",
|
|
5572
|
+
min_special_char: "minSpecialCharMessage",
|
|
5573
|
+
one_of: "oneOfMessage",
|
|
5574
|
+
readonly: "readOnlyMessage",
|
|
5575
|
+
busy: "busyMessage",
|
|
5576
|
+
available: "availableMessage",
|
|
5577
|
+
};
|
|
5578
|
+
|
|
5579
|
+
const CONSTRAINT_MESSAGE_PROP_NAME_SET = new Set(
|
|
5580
|
+
Object.values(CONSTRAINT_NAME_TO_PROP),
|
|
5581
|
+
);
|
|
5582
|
+
|
|
5583
|
+
const extractMessageAndRemainingProps = (props) => {
|
|
5584
|
+
const ownMessages = {};
|
|
5585
|
+
const remaining = {};
|
|
5586
|
+
const keyToVisit = new Set(Object.keys(props));
|
|
5587
|
+
for (const key of keyToVisit) {
|
|
5588
|
+
if (CONSTRAINT_MESSAGE_PROP_NAME_SET.has(key)) {
|
|
5589
|
+
ownMessages[key] = props[key];
|
|
5590
|
+
} else {
|
|
5591
|
+
remaining[key] = props[key];
|
|
5592
|
+
}
|
|
5593
|
+
}
|
|
5594
|
+
return [ownMessages, remaining];
|
|
5595
|
+
};
|
|
5596
|
+
|
|
5597
|
+
/**
|
|
5598
|
+
* The one-line form of a constraint message, or `undefined` when it has none.
|
|
5599
|
+
*
|
|
5600
|
+
* A message is not always a sentence: it can be an `Error`, or a whole element
|
|
5601
|
+
* (an `errorMapping` returning JSX to put a link inside the callout). Those
|
|
5602
|
+
* still render fine in the callout, but they have no one-line form, and
|
|
5603
|
+
* anything that needs one — a `title` attribute, a caller drawing its own
|
|
5604
|
+
* summary — must be given nothing rather than `String(message)`, which writes
|
|
5605
|
+
* "[object Object]" on the screen.
|
|
5606
|
+
*/
|
|
5607
|
+
const getMessageString = (message) => {
|
|
5608
|
+
if (typeof message === "string") {
|
|
5609
|
+
return message;
|
|
5610
|
+
}
|
|
5611
|
+
if (Error.isError(message)) {
|
|
5612
|
+
return message.message;
|
|
5613
|
+
}
|
|
5614
|
+
return undefined;
|
|
5615
|
+
};
|
|
5616
|
+
|
|
5617
|
+
const getConstraintMessage = (
|
|
5618
|
+
controller,
|
|
5619
|
+
constraint,
|
|
5620
|
+
generatedMessage,
|
|
5621
|
+
{ requester },
|
|
5622
|
+
) => {
|
|
5623
|
+
const { name: constraintName } = constraint;
|
|
5624
|
+
const propName = CONSTRAINT_NAME_TO_PROP[constraintName];
|
|
5625
|
+
|
|
5626
|
+
// 1. Search first on the requester (e.g. the <li> that was clicked),
|
|
5627
|
+
// then fall back to element (e.g. the hidden <input>).
|
|
5628
|
+
if (requester) {
|
|
5629
|
+
const requesterController = requester.__uiStateController__;
|
|
5630
|
+
if (requesterController && requesterController !== controller) {
|
|
5631
|
+
const requesterControllerMessage = requesterController.props[propName];
|
|
5632
|
+
if (requesterControllerMessage) {
|
|
5633
|
+
return {
|
|
5634
|
+
message: requesterControllerMessage,
|
|
5635
|
+
origin: "requester controller",
|
|
5636
|
+
};
|
|
5637
|
+
}
|
|
5638
|
+
}
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5641
|
+
const controllerMessage = controller.props[propName];
|
|
5642
|
+
if (controllerMessage) {
|
|
5643
|
+
return {
|
|
5644
|
+
message: controllerMessage,
|
|
5645
|
+
origin: "controller",
|
|
5646
|
+
};
|
|
5647
|
+
}
|
|
5648
|
+
|
|
5649
|
+
return {
|
|
5650
|
+
message: generatedMessage,
|
|
5651
|
+
origin: "generated message",
|
|
5652
|
+
};
|
|
5653
|
+
};
|
|
5654
|
+
|
|
5655
|
+
// prop that we'll set on the control.
|
|
5656
|
+
// CONSTRAINT_ATTRIBUTE_SET is consulted through controlAttributeFromProp()
|
|
5657
|
+
// rather than spread in here: a constraint registers into it when its own
|
|
5658
|
+
// module evaluates, so anything read at module-eval time reads a set that is
|
|
5659
|
+
// still filling up — and in a bundle, whichever constraint happens to evaluate
|
|
5660
|
+
// last would silently lose its attribute.
|
|
5661
|
+
const CONTROL_ATTRIBUTE_SET = new Set([
|
|
5662
|
+
"ref",
|
|
5663
|
+
"children",
|
|
5664
|
+
"id",
|
|
5665
|
+
"name",
|
|
5666
|
+
"type",
|
|
5667
|
+
"value",
|
|
5668
|
+
"checked",
|
|
5669
|
+
"placeholder",
|
|
5670
|
+
"inputMode",
|
|
5671
|
+
"autoComplete",
|
|
5672
|
+
"spellcheck",
|
|
5673
|
+
"autoCorrect",
|
|
5674
|
+
"aria-controls",
|
|
5675
|
+
// The name goes where the role is: the root box has none, so an aria-label
|
|
5676
|
+
// left on it names nothing at all, while the host is what the user focuses
|
|
5677
|
+
// and what a screen reader announces (and what getByRole({ name }) reads).
|
|
5678
|
+
"aria-label",
|
|
5140
5679
|
"aria-labelledby",
|
|
5141
5680
|
"tabIndex",
|
|
5142
5681
|
"command",
|
|
@@ -8376,12 +8915,7 @@ const REQUIRED_CONSTRAINT = {
|
|
|
8376
8915
|
if (type === "time") {
|
|
8377
8916
|
return naviI18n("constraint.required.time");
|
|
8378
8917
|
}
|
|
8379
|
-
|
|
8380
|
-
if (
|
|
8381
|
-
type === "number" ||
|
|
8382
|
-
inputMode === "numeric" ||
|
|
8383
|
-
inputMode === "decimal"
|
|
8384
|
-
) {
|
|
8918
|
+
if (isNumberInput(type, field.controlHostProps["navi-input-type"])) {
|
|
8385
8919
|
return naviI18n("constraint.required.number");
|
|
8386
8920
|
}
|
|
8387
8921
|
if (type === "datetime-local") {
|
|
@@ -8610,10 +9144,8 @@ const TYPE_NUMBER_CONSTRAINT = {
|
|
|
8610
9144
|
return null;
|
|
8611
9145
|
}
|
|
8612
9146
|
const type = field.controlHostProps.type;
|
|
8613
|
-
const
|
|
8614
|
-
|
|
8615
|
-
type === "number" || inputMode === "numeric" || inputMode === "decimal";
|
|
8616
|
-
if (!isNumber) {
|
|
9147
|
+
const naviType = field.controlHostProps["navi-input-type"];
|
|
9148
|
+
if (!isNumberInput(type, naviType)) {
|
|
8617
9149
|
return null;
|
|
8618
9150
|
}
|
|
8619
9151
|
const valueAsString =
|
|
@@ -8626,7 +9158,6 @@ const TYPE_NUMBER_CONSTRAINT = {
|
|
|
8626
9158
|
return null;
|
|
8627
9159
|
}
|
|
8628
9160
|
|
|
8629
|
-
const naviType = field.controlHostProps["navi-input-type"];
|
|
8630
9161
|
if (naviType === "hour") {
|
|
8631
9162
|
return naviI18n(`constraint.type.hour.default`);
|
|
8632
9163
|
}
|
|
@@ -8682,15 +9213,13 @@ const MIN_CONSTRAINT = {
|
|
|
8682
9213
|
return null;
|
|
8683
9214
|
}
|
|
8684
9215
|
const type = field.controlHostProps.type;
|
|
8685
|
-
const
|
|
9216
|
+
const naviInputType = field.controlHostProps["navi-input-type"];
|
|
8686
9217
|
const valueAsString =
|
|
8687
9218
|
field.uiState === undefined ? "" : String(field.uiState);
|
|
8688
9219
|
if (!valueAsString) {
|
|
8689
9220
|
return null;
|
|
8690
9221
|
}
|
|
8691
|
-
|
|
8692
|
-
type === "number" || inputMode === "numeric" || inputMode === "decimal";
|
|
8693
|
-
if (isNumber) {
|
|
9222
|
+
if (isNumberInput(type, naviInputType)) {
|
|
8694
9223
|
const minNumber = parseFloat(minString);
|
|
8695
9224
|
if (isNaN(minNumber)) {
|
|
8696
9225
|
return null;
|
|
@@ -8700,7 +9229,6 @@ const MIN_CONSTRAINT = {
|
|
|
8700
9229
|
return null;
|
|
8701
9230
|
}
|
|
8702
9231
|
if (numericValue < minNumber) {
|
|
8703
|
-
const naviInputType = field.controlHostProps["navi-input-type"];
|
|
8704
9232
|
if (naviInputType === "hour") {
|
|
8705
9233
|
return naviI18n(`constraint.min.hour.default`, {
|
|
8706
9234
|
min: minString,
|
|
@@ -8785,15 +9313,13 @@ const MAX_CONSTRAINT = {
|
|
|
8785
9313
|
return null;
|
|
8786
9314
|
}
|
|
8787
9315
|
const type = field.controlHostProps.type;
|
|
8788
|
-
const
|
|
9316
|
+
const naviInputType = field.controlHostProps["navi-input-type"];
|
|
8789
9317
|
const valueAsString =
|
|
8790
9318
|
field.uiState === undefined ? "" : String(field.uiState);
|
|
8791
9319
|
if (!valueAsString) {
|
|
8792
9320
|
return null;
|
|
8793
9321
|
}
|
|
8794
|
-
|
|
8795
|
-
type === "number" || inputMode === "numeric" || inputMode === "decimal";
|
|
8796
|
-
if (isNumber) {
|
|
9322
|
+
if (isNumberInput(type, naviInputType)) {
|
|
8797
9323
|
const maxNumber = parseFloat(maxString);
|
|
8798
9324
|
if (isNaN(maxNumber)) {
|
|
8799
9325
|
return null;
|
|
@@ -8806,7 +9332,6 @@ const MAX_CONSTRAINT = {
|
|
|
8806
9332
|
return null;
|
|
8807
9333
|
}
|
|
8808
9334
|
|
|
8809
|
-
const naviInputType = field.controlHostProps["navi-input-type"];
|
|
8810
9335
|
if (naviInputType === "hour") {
|
|
8811
9336
|
return naviI18n(`constraint.max.hour.default`, {
|
|
8812
9337
|
max: maxString,
|
|
@@ -8913,10 +9438,9 @@ const STEP_CONSTRAINT = {
|
|
|
8913
9438
|
return null;
|
|
8914
9439
|
}
|
|
8915
9440
|
const type = field.controlHostProps.type;
|
|
8916
|
-
const
|
|
8917
|
-
const
|
|
8918
|
-
|
|
8919
|
-
if (!isNumericText && !STEP_SUPPORTED_TYPE_SET.has(type)) {
|
|
9441
|
+
const naviInputType = field.controlHostProps["navi-input-type"];
|
|
9442
|
+
const isNumber = isNumberInput(type, naviInputType);
|
|
9443
|
+
if (!isNumber && !STEP_SUPPORTED_TYPE_SET.has(type)) {
|
|
8920
9444
|
return null;
|
|
8921
9445
|
}
|
|
8922
9446
|
const stepRaw = field.controlHostProps.step;
|
|
@@ -8930,7 +9454,6 @@ const STEP_CONSTRAINT = {
|
|
|
8930
9454
|
return null;
|
|
8931
9455
|
}
|
|
8932
9456
|
const minString = field.controlHostProps.min;
|
|
8933
|
-
const isNumber = type === "number" || isNumericText;
|
|
8934
9457
|
if (isNumber) {
|
|
8935
9458
|
const step = parseFloat(stepString);
|
|
8936
9459
|
const base = minString ? parseFloat(minString) : 0;
|
|
@@ -8949,7 +9472,6 @@ const STEP_CONSTRAINT = {
|
|
|
8949
9472
|
const after = before + step;
|
|
8950
9473
|
const decimals = (stepString.split(".")[1] || "").length;
|
|
8951
9474
|
const context = (() => {
|
|
8952
|
-
const naviInputType = field.controlHostProps["navi-input-type"];
|
|
8953
9475
|
if (naviInputType === "hour") {
|
|
8954
9476
|
return `hour`;
|
|
8955
9477
|
}
|
|
@@ -12097,8 +12619,8 @@ const TIME_RANGE_CONSTRAINT = {
|
|
|
12097
12619
|
console.warn(`Time after constraint: no control with id "${after}"`);
|
|
12098
12620
|
return null;
|
|
12099
12621
|
}
|
|
12100
|
-
const timeBefore = minutesFromTime
|
|
12101
|
-
const timeAfter = minutesFromTime
|
|
12622
|
+
const timeBefore = minutesFromTime(otherController.uiState);
|
|
12623
|
+
const timeAfter = minutesFromTime(field.uiState);
|
|
12102
12624
|
if (timeBefore === null || timeAfter === null) {
|
|
12103
12625
|
return null;
|
|
12104
12626
|
}
|
|
@@ -12123,7 +12645,7 @@ CONSTRAINT_ATTRIBUTE_SET.add("data-time-min-duration");
|
|
|
12123
12645
|
|
|
12124
12646
|
// "HH:MM" as a number of minutes, which is what two times are compared and
|
|
12125
12647
|
// subtracted as. Anything else is a time nobody has finished writing.
|
|
12126
|
-
const minutesFromTime
|
|
12648
|
+
const minutesFromTime = (time) => {
|
|
12127
12649
|
if (typeof time !== "string") {
|
|
12128
12650
|
return null;
|
|
12129
12651
|
}
|
|
@@ -34126,13 +34648,24 @@ const useOpenPropsEffectOnOpenController = (
|
|
|
34126
34648
|
// subsequent `open` change is a real, later toggle and should animate
|
|
34127
34649
|
// normally like any other interactive open/close.
|
|
34128
34650
|
const isFirstRunRef = useRef(true);
|
|
34129
|
-
//
|
|
34130
|
-
//
|
|
34651
|
+
// What `open` was on the previous run of the effect below. preact re-runs
|
|
34652
|
+
// an effect for a change of its deps — or for none at all, when a
|
|
34653
|
+
// `<Loading>` above the popup parked the subtree: preact/compat's Suspense
|
|
34654
|
+
// runs every hook cleanup in it and clears the deps of every effect, so the
|
|
34655
|
+
// next render of the parked component runs them all again. A run where
|
|
34656
|
+
// `open` did not change is that second kind, never a toggle.
|
|
34657
|
+
const lastRunOpenRef = useRef(undefined);
|
|
34658
|
+
// An open the popup is owed but cannot be given yet: the mount-time one,
|
|
34659
|
+
// from the first run below until the effect after it could schedule it, and
|
|
34660
|
+
// the one put back after a park (see above), which the same effect serves
|
|
34661
|
+
// once the element is in the document again.
|
|
34131
34662
|
const mountOpenOwedRef = useRef(null);
|
|
34132
34663
|
|
|
34133
34664
|
useLayoutEffect(() => {
|
|
34134
34665
|
const isFirstRun = isFirstRunRef.current;
|
|
34135
34666
|
isFirstRunRef.current = false;
|
|
34667
|
+
const openChanged = open !== lastRunOpenRef.current;
|
|
34668
|
+
lastRunOpenRef.current = open;
|
|
34136
34669
|
|
|
34137
34670
|
if (isFirstRun) {
|
|
34138
34671
|
const mountOpenReason = open || defaultOpen;
|
|
@@ -34163,6 +34696,22 @@ const useOpenPropsEffectOnOpenController = (
|
|
|
34163
34696
|
return undefined;
|
|
34164
34697
|
}
|
|
34165
34698
|
if (open) {
|
|
34699
|
+
if (!openChanged) {
|
|
34700
|
+
// The subtree was parked while the popup was open: the controller
|
|
34701
|
+
// closed when the element left the document (see useOpenController's
|
|
34702
|
+
// safety net), and whoever holds the open state still says open. Not
|
|
34703
|
+
// a toggle — an open owed until the dom is back in the page, where
|
|
34704
|
+
// the effect below serves it. Silent, like a mount-time open: the
|
|
34705
|
+
// popup was already shown, there is no closed state to enter from.
|
|
34706
|
+
// Returned here rather than falling through: the signal write below
|
|
34707
|
+
// would read the deferred open as a refused one and write the popup
|
|
34708
|
+
// closed.
|
|
34709
|
+
mountOpenOwedRef.current = () =>
|
|
34710
|
+
openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
|
|
34711
|
+
silent: true,
|
|
34712
|
+
});
|
|
34713
|
+
return undefined;
|
|
34714
|
+
}
|
|
34166
34715
|
openController.open(new CustomEvent("open_by_prop", { detail: {} }));
|
|
34167
34716
|
} else {
|
|
34168
34717
|
openController.requestClose(
|
|
@@ -34192,14 +34741,14 @@ const useOpenPropsEffectOnOpenController = (
|
|
|
34192
34741
|
return undefined;
|
|
34193
34742
|
}, [open]);
|
|
34194
34743
|
|
|
34195
|
-
// Schedules the owed
|
|
34196
|
-
//
|
|
34197
|
-
//
|
|
34198
|
-
//
|
|
34199
|
-
// (preact/compat),
|
|
34200
|
-
//
|
|
34201
|
-
//
|
|
34202
|
-
//
|
|
34744
|
+
// Schedules the owed open — on every render, until it can. It has to wait
|
|
34745
|
+
// for the element to be IN THE DOCUMENT, and a render does not guarantee
|
|
34746
|
+
// that: a `<Loading>` above the popup parks a suspended subtree by moving
|
|
34747
|
+
// its dom into a detached <div> while keeping its components alive
|
|
34748
|
+
// (preact/compat), so a run of the effect above can happen against dom that
|
|
34749
|
+
// is not in the page — where showModal() and showPopover() throw. The
|
|
34750
|
+
// boundary settling re-renders the subtree with its dom back, and that
|
|
34751
|
+
// render is the one that schedules.
|
|
34203
34752
|
//
|
|
34204
34753
|
// Deferred + batched (see scheduleMountOpen) rather than called directly,
|
|
34205
34754
|
// so nested popups that both mount already-open end up stacked
|
|
@@ -35538,19 +36087,29 @@ const FormContext = createContext();
|
|
|
35538
36087
|
*/
|
|
35539
36088
|
|
|
35540
36089
|
|
|
35541
|
-
const isTypingIntent = (e) =>
|
|
35542
|
-
|
|
36090
|
+
const isTypingIntent = (e) => getKeyboardEventDefaultAction(e) === "type";
|
|
36091
|
+
|
|
36092
|
+
// The character a key puts into the field, or null when it puts none there.
|
|
36093
|
+
// A typing intent covers Backspace and Delete too: they change the text without
|
|
36094
|
+
// bringing a character in, and both guards answer for the character coming in.
|
|
36095
|
+
// Counted in code points: an astral character is one character typed, not two.
|
|
36096
|
+
const getCharBeingInserted = (e) => {
|
|
36097
|
+
const key = e.key;
|
|
36098
|
+
if (key === "Enter") {
|
|
36099
|
+
// Reaching here means a field that takes a newline (a textarea): the line
|
|
36100
|
+
// break lands in the value like any other character.
|
|
36101
|
+
return "\n";
|
|
36102
|
+
}
|
|
36103
|
+
if ([...key].length !== 1) {
|
|
36104
|
+
return null;
|
|
36105
|
+
}
|
|
36106
|
+
return key;
|
|
36107
|
+
};
|
|
35543
36108
|
|
|
35544
36109
|
const s = (n) => (n > 1 ? "s" : "");
|
|
35545
36110
|
|
|
35546
|
-
// Keydown: block
|
|
35547
|
-
// Multi-character key names (Delete, ArrowLeft…) are always allowed.
|
|
36111
|
+
// Keydown: block a character that doesn't match the class.
|
|
35548
36112
|
const getInvalidCharMessage = (char, { charClass, messageKey }) => {
|
|
35549
|
-
// Counted in code points: an astral character is one character typed, not two.
|
|
35550
|
-
const codePointCount = [...char].length;
|
|
35551
|
-
if (codePointCount !== 1) {
|
|
35552
|
-
return null;
|
|
35553
|
-
}
|
|
35554
36113
|
if (compileCharClass(charClass).test(char)) return null;
|
|
35555
36114
|
return naviI18nFromValidityMessage({ key: messageKey });
|
|
35556
36115
|
};
|
|
@@ -35631,18 +36190,28 @@ const createControlGuard = (controller) => {
|
|
|
35631
36190
|
/**
|
|
35632
36191
|
* Called on every keydown. Returns true when the key should be blocked
|
|
35633
36192
|
* (caller must call e.preventDefault()).
|
|
35634
|
-
*
|
|
36193
|
+
* Keys that write nothing into the field (Arrow…) and keys that only remove
|
|
36194
|
+
* text (Backspace, Delete) are always allowed.
|
|
35635
36195
|
*/
|
|
35636
36196
|
const checkKeydown = (e, el) => {
|
|
35637
36197
|
if (!isTypingIntent(e)) {
|
|
35638
36198
|
return false;
|
|
35639
36199
|
}
|
|
36200
|
+
const char = getCharBeingInserted(e);
|
|
36201
|
+
if (char === null) {
|
|
36202
|
+
// Backspace/Delete: the value gets shorter and no character lands in it.
|
|
36203
|
+
// Neither the character class nor the length limit has anything to say
|
|
36204
|
+
// about a key that only removes — and refusing one would leave a full
|
|
36205
|
+
// field with no way to be shortened.
|
|
36206
|
+
clear(e);
|
|
36207
|
+
return false;
|
|
36208
|
+
}
|
|
35640
36209
|
const { charGuard, maxLengthGuard } = controller.props;
|
|
35641
36210
|
|
|
35642
36211
|
if (charGuard) {
|
|
35643
36212
|
const charClass = resolveCharClass(charGuard);
|
|
35644
36213
|
const messageKey = getCharClassMessageKey(charGuard);
|
|
35645
|
-
const charMsg = getInvalidCharMessage(
|
|
36214
|
+
const charMsg = getInvalidCharMessage(char, { charClass, messageKey });
|
|
35646
36215
|
if (charMsg) {
|
|
35647
36216
|
show(charMsg, e);
|
|
35648
36217
|
return true;
|
|
@@ -38359,7 +38928,7 @@ const useControlProps = (props, {
|
|
|
38359
38928
|
controlType,
|
|
38360
38929
|
id: props.id,
|
|
38361
38930
|
type: props.type,
|
|
38362
|
-
|
|
38931
|
+
naviInputType: props["navi-input-type"],
|
|
38363
38932
|
// How the value is WRITTEN where it is held one way and shown another —
|
|
38364
38933
|
// a number on two digits ("07" for 7). See asControlHostValue.
|
|
38365
38934
|
pad: props["navi-value-pad"]
|
|
@@ -52238,881 +52807,362 @@ const useAutoSelectReadOnly = (props) => {
|
|
|
52238
52807
|
if (!e.target.readOnly) {
|
|
52239
52808
|
return;
|
|
52240
52809
|
}
|
|
52241
|
-
if (lastPointerTypeRef.current === "touch") {
|
|
52242
|
-
return;
|
|
52243
|
-
}
|
|
52244
|
-
e.preventDefault();
|
|
52245
|
-
e.target.select();
|
|
52246
|
-
};
|
|
52247
|
-
|
|
52248
|
-
return { onFocus, onMouseDown, onPointerDown };
|
|
52249
|
-
};
|
|
52250
|
-
|
|
52251
|
-
/**
|
|
52252
|
-
* Input component for all textual input types.
|
|
52253
|
-
*
|
|
52254
|
-
* Note pour plus tard: un jour on voudra un cas field-sizing: content;
|
|
52255
|
-
*
|
|
52256
|
-
*
|
|
52257
|
-
* Supports:
|
|
52258
|
-
* - text (default)
|
|
52259
|
-
* - password
|
|
52260
|
-
* - hidden
|
|
52261
|
-
* - email
|
|
52262
|
-
* - url
|
|
52263
|
-
* - search
|
|
52264
|
-
* - tel
|
|
52265
|
-
* - etc.
|
|
52266
|
-
*
|
|
52267
|
-
* For non-textual inputs, specialized components will be used:
|
|
52268
|
-
* - <InputCheckbox /> for type="checkbox"
|
|
52269
|
-
* - <InputRadio /> for type="radio"
|
|
52270
|
-
*
|
|
52271
|
-
* Guard props (immediate feedback instead of wait-for-submit):
|
|
52272
|
-
*
|
|
52273
|
-
* - charGuard — restricts which characters can be typed, pasted, or set externally.
|
|
52274
|
-
* Accepts a preset name or a raw regex character class:
|
|
52275
|
-
* "numeric" → digits only, sets inputMode="numeric" + pattern auto
|
|
52276
|
-
* "alpha" → letters only
|
|
52277
|
-
* "alphanumeric" → letters and digits
|
|
52278
|
-
* "uppercase" → uppercase letters only
|
|
52279
|
-
* "tel" → phone chars (digits, +, -, parens, space), sets inputMode="tel"
|
|
52280
|
-
* "card" → credit card (digits and spaces), sets inputMode="numeric"
|
|
52281
|
-
* "hex" → hexadecimal digits
|
|
52282
|
-
* "pin" → numeric PIN, sets inputMode="numeric"
|
|
52283
|
-
* "postal" → postal code (digits, letters, space, hyphen)
|
|
52284
|
-
* "iban" → IBAN (uppercase and digits)
|
|
52285
|
-
* "slug" → URL slug (lowercase, digits, hyphens)
|
|
52286
|
-
* "noEmoji" → anything but an emoji
|
|
52287
|
-
* "[A-Z0-9]" → any custom regex character class, compiled with the `u`
|
|
52288
|
-
* flag: `\p{...}` is available, and an emoji counts as one
|
|
52289
|
-
* character rather than two halves.
|
|
52290
|
-
* inputMode and pattern are auto-derived from the preset when not explicitly set.
|
|
52291
|
-
* The presets come from @jsenv/validity, so the same name names the class a
|
|
52292
|
-
* server checks the value against (see docs/field_validation.md).
|
|
52293
|
-
*
|
|
52294
|
-
* - maxLengthGuard — combines maxLength + overflow guard in one prop.
|
|
52295
|
-
* Blocks keydown when the limit is reached; truncates on paste/set with an info callout.
|
|
52296
|
-
* The maxLength constraint remains active for form validation at submit.
|
|
52297
|
-
* Use plain maxLength (without maxLengthGuard) for submit-only validation.
|
|
52298
|
-
*
|
|
52299
|
-
* Background color:
|
|
52300
|
-
* - backgroundColor="transparent" applies at rest and hover; a focused field
|
|
52301
|
-
* turns solid (--navi-surface-color) so text is not typed over what sits behind.
|
|
52302
|
-
* - variant="discrete" drops background and border at rest; focus brings back
|
|
52303
|
-
* a solid surface. variant="discrete-border" does the same but keeps the border.
|
|
52304
|
-
* - variant="discrete" + backgroundColor: the color applies at rest and hover,
|
|
52305
|
-
* and the field goes transparent while focused.
|
|
52306
|
-
*
|
|
52307
|
-
* variant="text" is the odd one: it renders no <input> at all, just the value
|
|
52308
|
-
* as text — see InputTextualAsText below for what it is for and what it drops.
|
|
52309
|
-
*/
|
|
52310
|
-
|
|
52311
|
-
const InputHeadlessResolver = props => {
|
|
52312
|
-
const Next = useNextResolver();
|
|
52313
|
-
if (props.headless) {
|
|
52314
|
-
return jsx(InputTextualHeadless, {
|
|
52315
|
-
...props
|
|
52316
|
-
});
|
|
52317
|
-
}
|
|
52318
|
-
if (props.type === "hidden") {
|
|
52319
|
-
return jsx(InputHidden, {
|
|
52320
|
-
...props
|
|
52321
|
-
});
|
|
52322
|
-
}
|
|
52323
|
-
return jsx(Next, {
|
|
52324
|
-
...props
|
|
52325
|
-
});
|
|
52326
|
-
};
|
|
52327
|
-
const InputHidden = props => {
|
|
52328
|
-
const [inputRootProps, inputHostProps] = useInputTextualProps(props);
|
|
52329
|
-
return jsx(RealInput, {
|
|
52330
|
-
...inputRootProps,
|
|
52331
|
-
...inputHostProps
|
|
52332
|
-
});
|
|
52333
|
-
};
|
|
52334
|
-
const InputTextualHeadless = props => {
|
|
52335
|
-
const [inputRootProps, inputHostProps] = useInputTextualProps(props);
|
|
52336
|
-
return jsx(RealInput, {
|
|
52337
|
-
"navi-visually-hidden": "",
|
|
52338
|
-
"navi-focus-delegate": "",
|
|
52339
|
-
"aria-hidden": "true",
|
|
52340
|
-
...inputRootProps,
|
|
52341
|
-
...inputHostProps
|
|
52342
|
-
});
|
|
52343
|
-
};
|
|
52344
|
-
const useInputTextualProps = props => {
|
|
52345
|
-
return useControlProps(props, {
|
|
52346
|
-
controlType: "input"
|
|
52347
|
-
});
|
|
52348
|
-
};
|
|
52349
|
-
const InputTextualUI = props => {
|
|
52350
|
-
installInputCss();
|
|
52351
|
-
const {
|
|
52352
|
-
ui,
|
|
52353
|
-
variant,
|
|
52354
|
-
backgroundColor,
|
|
52355
|
-
width = "maxLength"
|
|
52356
|
-
} = props;
|
|
52357
|
-
const [inputControlRootProps, inputControlHostProps, controlChildrenWrapperProps] = useInputTextualProps(props);
|
|
52358
|
-
const {
|
|
52359
|
-
id,
|
|
52360
|
-
basePseudoState,
|
|
52361
|
-
children
|
|
52362
|
-
} = inputControlHostProps;
|
|
52363
|
-
const {
|
|
52364
|
-
uiStateController
|
|
52365
|
-
} = controlChildrenWrapperProps;
|
|
52366
|
-
const value = uiStateController.uiState;
|
|
52367
|
-
const disabled = basePseudoState[":disabled"];
|
|
52368
|
-
const readOnly = basePseudoState[":read-only"];
|
|
52369
|
-
const loading = basePseudoState[":-navi-loading"];
|
|
52370
|
-
const childrenWithContext = jsx(ControlChildrenWrapper, {
|
|
52371
|
-
...controlChildrenWrapperProps,
|
|
52372
|
-
children: jsx(InputTextualContext.Provider, {
|
|
52373
|
-
value: {
|
|
52374
|
-
id,
|
|
52375
|
-
readOnly,
|
|
52376
|
-
disabled,
|
|
52377
|
-
value
|
|
52378
|
-
},
|
|
52379
|
-
children: children || ui
|
|
52380
|
-
})
|
|
52381
|
-
});
|
|
52382
|
-
|
|
52383
|
-
// meant to end on input
|
|
52384
|
-
// we have to use delete otherwise it could override width: undefined
|
|
52385
|
-
// when remainingProps contains expandX which would try to set width to 100%
|
|
52386
|
-
delete inputControlRootProps.width;
|
|
52387
|
-
if (width === "maxLength") {
|
|
52388
|
-
const widthFromMaxLength = resolveWidthFromMaxLength(inputControlHostProps.maxLength, props.inputMode);
|
|
52389
|
-
if (widthFromMaxLength !== undefined) {
|
|
52390
|
-
inputControlHostProps.width = widthFromMaxLength;
|
|
52391
|
-
}
|
|
52392
|
-
} else if (width === "content") {
|
|
52393
|
-
inputControlHostProps.fieldSizing = "content";
|
|
52394
|
-
} else {
|
|
52395
|
-
inputControlHostProps.width = width;
|
|
52396
|
-
}
|
|
52397
|
-
return jsxs(Box, {
|
|
52398
|
-
as: "span",
|
|
52399
|
-
inline: true,
|
|
52400
|
-
flex: true,
|
|
52401
|
-
baseClassName: "navi_input",
|
|
52402
|
-
...inputControlRootProps,
|
|
52403
|
-
basePseudoState: basePseudoState,
|
|
52404
|
-
ui: undefined,
|
|
52405
|
-
"data-variant": variant || undefined,
|
|
52406
|
-
"data-background": backgroundColor !== undefined && backgroundColor !== "transparent" ? "" : undefined,
|
|
52407
|
-
"data-background-transparent": backgroundColor === "transparent" ? "" : undefined,
|
|
52408
|
-
styleCSSVars: InputStyleCSSVars,
|
|
52409
|
-
pseudoStateSelector: ".navi_control_input",
|
|
52410
|
-
pseudoClasses: InputPseudoClasses,
|
|
52411
|
-
pseudoElements: InputPseudoElements
|
|
52412
|
-
// input may have left/right icons and we want the anchor to target the input element
|
|
52413
|
-
// which is where the interaction can happen
|
|
52414
|
-
,
|
|
52415
|
-
"data-callout-anchor": ".navi_control_input",
|
|
52416
|
-
children: [jsx(LoadingOutline, {
|
|
52417
|
-
loading: loading,
|
|
52418
|
-
color: "var(--loader-color)",
|
|
52419
|
-
inset: -1
|
|
52420
|
-
}), variant === "underline" ? jsxs("span", {
|
|
52421
|
-
className: "navi_input_real_input_wrapper",
|
|
52422
|
-
children: [jsx(RealInput, {
|
|
52423
|
-
...inputControlHostProps
|
|
52424
|
-
}), jsx("span", {
|
|
52425
|
-
className: "navi_input_underline"
|
|
52426
|
-
})]
|
|
52427
|
-
}) : jsx(RealInput, {
|
|
52428
|
-
...inputControlHostProps
|
|
52429
|
-
}), childrenWithContext]
|
|
52430
|
-
});
|
|
52431
|
-
};
|
|
52432
|
-
// How wide a field is when its width is left to what it accepts: a value that
|
|
52433
|
-
// cannot exceed maxLength characters needs no more room than that. Shared with
|
|
52434
|
-
// the text variant, which must land on the same number or the two would not be
|
|
52435
|
-
// the same box.
|
|
52436
|
-
const resolveWidthFromMaxLength = (maxLength, inputMode) => {
|
|
52437
|
-
if (maxLength === undefined) {
|
|
52438
|
-
return undefined;
|
|
52439
|
-
}
|
|
52440
|
-
if (inputMode === "numeric") {
|
|
52441
|
-
return `${maxLength}ch`;
|
|
52442
|
-
}
|
|
52443
|
-
return `calc(${maxLength} * 1.5ch)`;
|
|
52444
|
-
};
|
|
52445
|
-
|
|
52446
|
-
/**
|
|
52447
|
-
* variant="text" — the value, written where the field would be and taking
|
|
52448
|
-
* exactly its room: same paddings, same font, same line, and the border kept
|
|
52449
|
-
* but made invisible, so a value one only reads and the same value being
|
|
52450
|
-
* edited are one box. What it is for: an information that is sometimes known
|
|
52451
|
-
* (a name already on the profile) and sometimes asked for. Swapping the field
|
|
52452
|
-
* for its text must move nothing under it.
|
|
52453
|
-
*
|
|
52454
|
-
* It is text, and nothing else: no <input>, so nothing to focus, nothing in
|
|
52455
|
-
* the tab order, and nothing sent when the form is submitted — a value the
|
|
52456
|
-
* form must carry goes in an <Input type="hidden"> beside this one. Not a
|
|
52457
|
-
* disabled control either: `disabled`/`aria-disabled` would announce a field
|
|
52458
|
-
* one cannot use, where there is no field at all.
|
|
52459
|
-
*
|
|
52460
|
-
* The field's own props are dropped rather than half-honoured (placeholder,
|
|
52461
|
-
* the guards, the slots): they all describe an edition that does not happen
|
|
52462
|
-
* here. What is kept is what decides the box.
|
|
52463
|
-
*/
|
|
52464
|
-
// Everything a field takes that a text does not: what the value is said with,
|
|
52465
|
-
// what the box is measured from (read below, then dropped too), and all the
|
|
52466
|
-
// rest — the guards, the slots, the constraints — which describe an edition
|
|
52467
|
-
// that does not happen here. What survives is what a Box understands: width,
|
|
52468
|
-
// spacing, colors, className, style.
|
|
52469
|
-
const INPUT_ONLY_PROPS = ["value", "defaultValue", "signal", "id", "maxLength", "inputMode", "width", "variant", "type", "name", "placeholder", "required", "readOnly", "disabled", "loading", "error", "min", "max", "step", "pattern", "autoComplete", "autoCorrect", "spellcheck", "charGuard", "maxLengthGuard", "list", "suggestions", "headless", "fieldSizing", "action", "uiAction", "ui", "children"];
|
|
52470
|
-
const InputTextualAsText = props => {
|
|
52471
|
-
installInputCss();
|
|
52472
|
-
// The id a Field handed down, which is what its Label points at.
|
|
52473
|
-
const controlId = useContext(ControlIdContext);
|
|
52474
|
-
const {
|
|
52475
|
-
value,
|
|
52476
|
-
defaultValue,
|
|
52477
|
-
signal,
|
|
52478
|
-
id,
|
|
52479
|
-
maxLength,
|
|
52480
|
-
inputMode,
|
|
52481
|
-
width = "maxLength"
|
|
52482
|
-
} = props;
|
|
52483
|
-
const valueShown = signal ? signal.value : value ?? defaultValue;
|
|
52484
|
-
const textWidth = width === "maxLength" ? resolveWidthFromMaxLength(maxLength, inputMode) : width === "content" ? undefined : width;
|
|
52485
|
-
const boxProps = {
|
|
52486
|
-
...props
|
|
52487
|
-
};
|
|
52488
|
-
for (const inputOnlyProp of INPUT_ONLY_PROPS) {
|
|
52489
|
-
delete boxProps[inputOnlyProp];
|
|
52490
|
-
}
|
|
52491
|
-
return jsx(Box, {
|
|
52492
|
-
as: "span",
|
|
52493
|
-
inline: true,
|
|
52494
|
-
flex: true,
|
|
52495
|
-
baseClassName: "navi_input",
|
|
52496
|
-
"data-variant": "text",
|
|
52497
|
-
styleCSSVars: InputStyleCSSVars,
|
|
52498
|
-
...boxProps,
|
|
52499
|
-
children: jsx(Box, {
|
|
52500
|
-
as: "span",
|
|
52501
|
-
baseClassName: "navi_input_text",
|
|
52502
|
-
id: id || controlId,
|
|
52503
|
-
width: textWidth,
|
|
52504
|
-
children: jsx("span", {
|
|
52505
|
-
className: "navi_input_text_value",
|
|
52506
|
-
children: valueShown
|
|
52507
|
-
})
|
|
52508
|
-
})
|
|
52509
|
-
});
|
|
52510
|
-
};
|
|
52511
|
-
const InputTextualAsTextResolver = props => {
|
|
52512
|
-
const Next = useNextResolver();
|
|
52513
|
-
if (props.variant === "text") {
|
|
52514
|
-
return jsx(InputTextualAsText, {
|
|
52515
|
-
...props
|
|
52516
|
-
});
|
|
52517
|
-
}
|
|
52518
|
-
return jsx(Next, {
|
|
52519
|
-
...props
|
|
52520
|
-
});
|
|
52521
|
-
};
|
|
52522
|
-
const InputTextualFirstResolver = props => {
|
|
52523
|
-
const Next = useNextResolver();
|
|
52524
|
-
const defaultRef = useRef(null);
|
|
52525
|
-
props.ref = props.ref || defaultRef;
|
|
52526
|
-
return jsx(Next, {
|
|
52527
|
-
...props
|
|
52528
|
-
});
|
|
52529
|
-
};
|
|
52530
|
-
const InputTextual = /*#__PURE__*/createComponentResolver([InputTextualAsTextResolver, InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
|
|
52531
|
-
const RealInput = ({
|
|
52532
|
-
maxLength,
|
|
52533
|
-
...domProps
|
|
52534
|
-
}) => {
|
|
52535
|
-
const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
|
|
52536
|
-
return jsx(Box, {
|
|
52537
|
-
...domProps,
|
|
52538
|
-
as: "input",
|
|
52539
|
-
baseClassName: "navi_control_input",
|
|
52540
|
-
...autoSelectReadOnlyProps,
|
|
52541
|
-
// Never set native maxLength — our guard handles it. Omitting it entirely
|
|
52542
|
-
// avoids a Preact quirk: setting maxLength={undefined} on a fresh DOM element
|
|
52543
|
-
// (e.g. after a Suspense remount) causes Preact to run `el.maxLength = ""`
|
|
52544
|
-
// which coerces to 0 (Number("") = 0), capping the input at 0 characters.
|
|
52545
|
-
// see https://github.com/preactjs/preact/issues/2677
|
|
52546
|
-
// The JS value stays accessible via the navi-max-length attribute and via
|
|
52547
|
-
// inputControlHostProps (which the validation system reads directly).
|
|
52548
|
-
"navi-max-length": maxLength
|
|
52549
|
-
});
|
|
52550
|
-
};
|
|
52551
|
-
|
|
52552
|
-
// Shared with textarea.jsx: a textarea is styled as a .navi_input box, so the
|
|
52553
|
-
// two read the same style props and pseudo states.
|
|
52554
|
-
const InputStyleCSSVars = {
|
|
52555
|
-
"slotSpacing": ["--slot-spacing", "margin"],
|
|
52556
|
-
"outlineWidth": "--outline-width",
|
|
52557
|
-
"borderWidth": "--border-width",
|
|
52558
|
-
"borderRadius": "--border-radius",
|
|
52559
|
-
"padding": "--padding",
|
|
52560
|
-
"paddingX": "--padding-x",
|
|
52561
|
-
"paddingY": "--padding-y",
|
|
52562
|
-
"paddingTop": "--padding-top",
|
|
52563
|
-
"paddingRight": "--padding-right",
|
|
52564
|
-
"paddingBottom": "--padding-bottom",
|
|
52565
|
-
"paddingLeft": "--padding-left",
|
|
52566
|
-
"background": "--background",
|
|
52567
|
-
"backgroundColor": "--background-color",
|
|
52568
|
-
"borderColor": "--border-color",
|
|
52569
|
-
"color": "--color",
|
|
52570
|
-
"fontSize": "--font-size",
|
|
52571
|
-
":hover": {
|
|
52572
|
-
backgroundColor: "--background-color-hover",
|
|
52573
|
-
borderColor: "--border-color-hover",
|
|
52574
|
-
color: "--color-hover"
|
|
52575
|
-
},
|
|
52576
|
-
":focus": {
|
|
52577
|
-
backgroundColor: "--background-color-focus",
|
|
52578
|
-
borderColor: "--border-color-focus"
|
|
52579
|
-
},
|
|
52580
|
-
":active": {
|
|
52581
|
-
backgroundColor: "--background-color-active",
|
|
52582
|
-
borderColor: "--border-color-active"
|
|
52583
|
-
},
|
|
52584
|
-
":read-only": {
|
|
52585
|
-
backgroundColor: "--background-color-readonly",
|
|
52586
|
-
borderColor: "--border-color-readonly",
|
|
52587
|
-
color: "--color-readonly"
|
|
52588
|
-
},
|
|
52589
|
-
":disabled": {
|
|
52590
|
-
backgroundColor: "--background-color-disabled",
|
|
52591
|
-
borderColor: "--border-color-disabled",
|
|
52592
|
-
color: "--color-disabled"
|
|
52593
|
-
}
|
|
52594
|
-
};
|
|
52595
|
-
const InputPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading", ":-navi-has-value"];
|
|
52596
|
-
const InputPseudoElements = ["::-navi-loader"];
|
|
52597
|
-
|
|
52598
|
-
/**
|
|
52599
|
-
* Parses a time string into seconds.
|
|
52600
|
-
* Accepts:
|
|
52601
|
-
* - number: returned as-is (already in seconds)
|
|
52602
|
-
* - "HH:MM" string: converted to seconds (e.g. "00:30" → 1800, "01:00" → 3600)
|
|
52603
|
-
* - undefined/null: returned as-is
|
|
52604
|
-
*/
|
|
52605
|
-
const timeStringToSeconds = (timeString) => {
|
|
52606
|
-
if (typeof timeString !== "string") {
|
|
52607
|
-
return timeString;
|
|
52608
|
-
}
|
|
52609
|
-
const colonIndex = timeString.indexOf(":");
|
|
52610
|
-
if (colonIndex === -1) {
|
|
52611
|
-
return Number(timeString);
|
|
52612
|
-
}
|
|
52613
|
-
const hours = parseInt(timeString.slice(0, colonIndex), 10);
|
|
52614
|
-
const minutes = parseInt(timeString.slice(colonIndex + 1), 10);
|
|
52615
|
-
return (hours * 60 + minutes) * 60;
|
|
52616
|
-
};
|
|
52617
|
-
|
|
52618
|
-
const isToday = (value) => {
|
|
52619
|
-
if (!value) {
|
|
52620
|
-
return false;
|
|
52621
|
-
}
|
|
52622
|
-
const now = new Date();
|
|
52623
|
-
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
52624
|
-
if (typeof value === "string") {
|
|
52625
|
-
return value === todayStr;
|
|
52626
|
-
}
|
|
52627
|
-
if (typeof value === "number") {
|
|
52628
|
-
const d = new Date(value);
|
|
52629
|
-
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
52630
|
-
return s === todayStr;
|
|
52631
|
-
}
|
|
52632
|
-
if (value instanceof Date) {
|
|
52633
|
-
const s = `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
52634
|
-
return s === todayStr;
|
|
52635
|
-
}
|
|
52636
|
-
return false;
|
|
52637
|
-
};
|
|
52638
|
-
|
|
52639
|
-
/**
|
|
52640
|
-
* Returns the current time as "HH:MM", with an optional minute offset.
|
|
52641
|
-
*
|
|
52642
|
-
* @param {number} [offsetMinutes=0] - Minutes to add (negative = subtract).
|
|
52643
|
-
* E.g. getNowHours(-5) returns "now minus 5 minutes".
|
|
52644
|
-
*
|
|
52645
|
-
* @example
|
|
52646
|
-
* getNowHours() // "14:30"
|
|
52647
|
-
* getNowHours(-5) // "14:25"
|
|
52648
|
-
*/
|
|
52649
|
-
const getNowHours = (offsetMinutes = 0) => {
|
|
52650
|
-
const now = new Date();
|
|
52651
|
-
const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
|
|
52652
|
-
const clamped =
|
|
52653
|
-
totalMinutes < 0
|
|
52654
|
-
? 0
|
|
52655
|
-
: totalMinutes > 23 * 60 + 59
|
|
52656
|
-
? 23 * 60 + 59
|
|
52657
|
-
: totalMinutes;
|
|
52658
|
-
const h = Math.floor(clamped / 60);
|
|
52659
|
-
const m = clamped % 60;
|
|
52660
|
-
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
52661
|
-
};
|
|
52662
|
-
|
|
52663
|
-
/**
|
|
52664
|
-
* Returns the current time rounded up to the nearest step boundary,
|
|
52665
|
-
* with an optional minute offset applied first.
|
|
52666
|
-
*
|
|
52667
|
-
* This is useful to compute a step-aligned `min` for a time picker:
|
|
52668
|
-
* passing it ensures the first available slot is always on a step boundary.
|
|
52669
|
-
*
|
|
52670
|
-
* @param {number} stepMinutes - Step size in minutes (e.g. 30).
|
|
52671
|
-
* @param {number} [offsetMinutes=0] - Minutes to add before rounding (negative = subtract).
|
|
52672
|
-
*
|
|
52673
|
-
* @example
|
|
52674
|
-
* // At 9:32, step 30, offset -5 → raw = 9:27 → ceil to 30 → "09:30"
|
|
52675
|
-
* // At 9:38, step 30, offset -5 → raw = 9:33 → ceil to 30 → "10:00"
|
|
52676
|
-
* getNowHoursRoundedToStep(30, -5)
|
|
52677
|
-
*/
|
|
52678
|
-
const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
|
|
52679
|
-
const now = new Date();
|
|
52680
|
-
const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
|
|
52681
|
-
const aligned = Math.ceil(totalMinutes / stepMinutes) * stepMinutes;
|
|
52682
|
-
const clamped =
|
|
52683
|
-
aligned < 0 ? 0 : aligned > 23 * 60 + 59 ? 23 * 60 + 59 : aligned;
|
|
52684
|
-
const h = Math.floor(clamped / 60);
|
|
52685
|
-
const m = clamped % 60;
|
|
52686
|
-
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
52687
|
-
};
|
|
52688
|
-
|
|
52689
|
-
/**
|
|
52690
|
-
* "HH:MM" and its two numbers, in both directions — what any control made of an
|
|
52691
|
-
* hour beside a minute (fields, wheels) aggregates to and is placed from. Held
|
|
52692
|
-
* as numbers, written on two digits: how they are shown is each control's own
|
|
52693
|
-
* business.
|
|
52694
|
-
*/
|
|
52695
|
-
const parseTimeParts = (time) => {
|
|
52696
|
-
if (typeof time !== "string") {
|
|
52697
|
-
return null;
|
|
52698
|
-
}
|
|
52699
|
-
const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
|
|
52700
|
-
if (!match) {
|
|
52701
|
-
return null;
|
|
52702
|
-
}
|
|
52703
|
-
return { hour: Number(match[1]), minute: Number(match[2]) };
|
|
52704
|
-
};
|
|
52705
|
-
|
|
52706
|
-
// Half a time is not a time: a control holding one of the two and nothing in
|
|
52707
|
-
// the other has no value at all, and a form has nothing to send about it.
|
|
52708
|
-
const formatTimeParts = (hour, minute) => {
|
|
52709
|
-
if (
|
|
52710
|
-
hour === "" ||
|
|
52711
|
-
hour === undefined ||
|
|
52712
|
-
minute === "" ||
|
|
52713
|
-
minute === undefined
|
|
52714
|
-
) {
|
|
52715
|
-
return undefined;
|
|
52716
|
-
}
|
|
52717
|
-
return `${padTwo$1(hour)}:${padTwo$1(minute)}`;
|
|
52718
|
-
};
|
|
52719
|
-
|
|
52720
|
-
const minutesFromTime = (time) => {
|
|
52721
|
-
const parts = parseTimeParts(time);
|
|
52722
|
-
if (!parts) {
|
|
52723
|
-
return null;
|
|
52724
|
-
}
|
|
52725
|
-
return parts.hour * 60 + parts.minute;
|
|
52726
|
-
};
|
|
52727
|
-
|
|
52728
|
-
const timeFromMinutes = (minutes) => {
|
|
52729
|
-
const inDay =
|
|
52730
|
-
((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
|
|
52731
|
-
return `${padTwo$1(Math.floor(inDay / 60))}:${padTwo$1(inDay % 60)}`;
|
|
52732
|
-
};
|
|
52733
|
-
|
|
52734
|
-
const MINUTES_PER_DAY = 24 * 60;
|
|
52735
|
-
|
|
52736
|
-
const padTwo$1 = (value) => String(value).padStart(2, "0");
|
|
52737
|
-
|
|
52738
|
-
// Maps validity type names → navi input type names.
|
|
52739
|
-
// Numeric signal types must not fall through to the native type="number"
|
|
52740
|
-
// (which adds spinner buttons and has poor UX) — they map to navi_number instead.
|
|
52741
|
-
const VALIDITY_TYPE_TO_INPUT_TYPE = {
|
|
52742
|
-
boolean: "checkbox",
|
|
52743
|
-
number: "navi_number",
|
|
52744
|
-
integer: "navi_number",
|
|
52745
|
-
percentage: "navi_percentage",
|
|
52746
|
-
};
|
|
52747
|
-
|
|
52748
|
-
// Conceptual number types: define defaults and map to native type="number".
|
|
52749
|
-
// The `data-navi-input-type` attribute is set so constraint messages can use
|
|
52750
|
-
// domain-specific wording instead of the generic "Ce nombre doit être...".
|
|
52751
|
-
const NAVI_TYPE_DEFAULTS = {
|
|
52752
|
-
navi_time: {
|
|
52753
|
-
"type": "time",
|
|
52754
|
-
"navi-input-type": "time",
|
|
52755
|
-
"min": 0,
|
|
52756
|
-
"max": 24 * 3600 - 1,
|
|
52757
|
-
"step": 1,
|
|
52758
|
-
},
|
|
52759
|
-
navi_percentage: {
|
|
52760
|
-
"type": "navi_number",
|
|
52761
|
-
"navi-input-type": "percentage",
|
|
52762
|
-
"min": 0,
|
|
52763
|
-
"max": 100,
|
|
52764
|
-
"step": 1,
|
|
52765
|
-
},
|
|
52766
|
-
navi_number: {
|
|
52767
|
-
type: "text",
|
|
52768
|
-
autoCorrect: "off",
|
|
52769
|
-
spellcheck: false,
|
|
52770
|
-
autoComplete: "off",
|
|
52771
|
-
},
|
|
52772
|
-
};
|
|
52773
|
-
|
|
52774
|
-
/**
|
|
52775
|
-
* resolveInputProps — normalizes input-related props that are shared across
|
|
52776
|
-
* `<Picker>`, `<Input>` (textual) and `<Range>`. Mutates the props object in place.
|
|
52777
|
-
*
|
|
52778
|
-
* Normalization is applied recursively: a navi type may resolve to another navi
|
|
52779
|
-
* type (e.g. `navi_percentage` → `navi_number` → `text`), and each step applies its
|
|
52780
|
-
* own formatters and defaults before moving to the next.
|
|
52781
|
-
*
|
|
52782
|
-
* Steps applied for each type:
|
|
52783
|
-
* 1. Record the original navi type in `props["navi-input-type"]` (first call only).
|
|
52784
|
-
* 2. Apply defaults for the current type (min, max, step, and any other props),
|
|
52785
|
-
* only when the prop is not already set.
|
|
52786
|
-
* 3. Apply min/max formatters (e.g. HH:MM string → number for duration types,
|
|
52787
|
-
* Date → formatted string for date/time types).
|
|
52788
|
-
* 4. Apply step formatter (same conversion rules).
|
|
52789
|
-
* 5. Remap `props.type` to the target type defined by the current type's defaults,
|
|
52790
|
-
* then recurse.
|
|
52791
|
-
*
|
|
52792
|
-
* Supported navi types and their targets:
|
|
52793
|
-
* - `navi_percentage` → `navi_number` (0–100, step 1)
|
|
52794
|
-
* - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
|
|
52795
|
-
* - `navi_time` → `time` (step in seconds)
|
|
52796
|
-
*
|
|
52797
|
-
* Standard HTML input types with formatters:
|
|
52798
|
-
* - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
|
|
52799
|
-
* min/max accept `Date` instances or timestamps and are converted to the
|
|
52800
|
-
* string format expected by the native input.
|
|
52801
|
-
* - `time`, `datetime-local`, `datetime`:
|
|
52802
|
-
* step accepts `"HH:MM"` and is converted to seconds.
|
|
52803
|
-
*/
|
|
52804
|
-
/**
|
|
52805
|
-
* A bound signal that carries a default of its own says the same thing on every
|
|
52806
|
-
* control: the control starts there and stays uncontrolled, which is what makes
|
|
52807
|
-
* a form read the value shown as a SUGGESTION rather than as something it
|
|
52808
|
-
* already holds. Uncontrolled here is about what the control HOLDS, not about
|
|
52809
|
-
* whether it follows the signal — the binding stays two-way either way (see
|
|
52810
|
-
* stateFromSignal in control_hooks.jsx). Written once and used by everything
|
|
52811
|
-
* that takes a `signal`, so one signal cannot mean two different things
|
|
52812
|
-
* depending on which control it was handed to.
|
|
52813
|
-
*/
|
|
52814
|
-
const seedDefaultValueFromSignal = (props) => {
|
|
52815
|
-
const signalOptions = props.signal?.options;
|
|
52816
|
-
if (!signalOptions) {
|
|
52817
|
-
return;
|
|
52818
|
-
}
|
|
52819
|
-
if (Object.hasOwn(props, "defaultValue")) {
|
|
52820
|
-
// explicit defaultValue prop prevails
|
|
52821
|
-
return;
|
|
52822
|
-
}
|
|
52823
|
-
// Snapshot the signal's current default so that resetUIState restores to the
|
|
52824
|
-
// original default — not the value the signal had at the time of the last
|
|
52825
|
-
// re-render.
|
|
52826
|
-
const defaultValue = signalOptions.getDefaultValue(false);
|
|
52827
|
-
if (defaultValue !== undefined) {
|
|
52828
|
-
props.defaultValue = defaultValue;
|
|
52829
|
-
}
|
|
52830
|
-
};
|
|
52831
|
-
|
|
52832
|
-
const resolveInputProps = (props, { controlType = "input" } = {}) => {
|
|
52833
|
-
// `signal` carries a bound state signal. It is left on `props` on purpose:
|
|
52834
|
-
// `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
|
|
52835
|
-
// follow it, and `onUIAction` (ui_state_controller.js) writes user
|
|
52836
|
-
// interactions back into it. Here we only derive input defaults (type/min/max,
|
|
52837
|
-
// defaultValue/defaultChecked) from the signal's `options`, so the control
|
|
52838
|
-
// ends up uncontrolled-with-default while still bound to the signal.
|
|
52839
|
-
const signal = props.signal;
|
|
52840
|
-
if (signal) {
|
|
52841
|
-
const signalOptions = signal.options;
|
|
52842
|
-
if (signalOptions) {
|
|
52843
|
-
for (const key of ["min", "max", "step"]) {
|
|
52844
|
-
if (props[key] === undefined && signalOptions[key] !== undefined) {
|
|
52845
|
-
props[key] = signalOptions[key];
|
|
52846
|
-
}
|
|
52847
|
-
}
|
|
52848
|
-
if (props.type === undefined && signalOptions.type !== undefined) {
|
|
52849
|
-
const typeFromSignal =
|
|
52850
|
-
VALIDITY_TYPE_TO_INPUT_TYPE[signalOptions.type] ?? signalOptions.type;
|
|
52851
|
-
// What a signal says is what its value IS; what a control's `type` says
|
|
52852
|
-
// is what the control is. They usually agree — a date-typed signal wants
|
|
52853
|
-
// a date field — but a boolean one maps to a checkbox, and a picker made
|
|
52854
|
-
// into a checkbox is not a picker with a different look: it is another
|
|
52855
|
-
// control, with no popup to open. A picker asked to hold a yes/no keeps
|
|
52856
|
-
// its two rows and stays itself.
|
|
52857
|
-
const wouldChangeWhatTheControlIs =
|
|
52858
|
-
controlType === "picker" &&
|
|
52859
|
-
(typeFromSignal === "checkbox" || typeFromSignal === "radio");
|
|
52860
|
-
if (!wouldChangeWhatTheControlIs) {
|
|
52861
|
-
props.type = typeFromSignal;
|
|
52862
|
-
}
|
|
52863
|
-
}
|
|
52864
|
-
}
|
|
52865
|
-
|
|
52866
|
-
const isCheckable = props.type === "checkbox" || props.type === "radio";
|
|
52867
|
-
if (isCheckable) {
|
|
52868
|
-
if (Object.hasOwn(props, "defaultChecked")) ; else {
|
|
52869
|
-
// If no explicit defaultChecked, derive it from the signal's default
|
|
52870
|
-
// value so that resetUIState restores to the original default.
|
|
52871
|
-
// Only a stateSignal carries a default of its own; a plain signal has
|
|
52872
|
-
// no `options` at all, and asking it for one used to throw on mount —
|
|
52873
|
-
// the same optional read every other branch here already does.
|
|
52874
|
-
const defaultVal = signalOptions?.getDefaultValue(false);
|
|
52875
|
-
if (defaultVal === undefined) ; else if (props.type === "radio") {
|
|
52876
|
-
if (defaultVal === true) {
|
|
52877
|
-
props.defaultChecked = true;
|
|
52878
|
-
} else if (
|
|
52879
|
-
Object.hasOwn(props, "value") &&
|
|
52880
|
-
defaultVal === props.value
|
|
52881
|
-
) {
|
|
52882
|
-
props.defaultChecked = true;
|
|
52883
|
-
}
|
|
52884
|
-
} else if (typeof defaultVal === "boolean") {
|
|
52885
|
-
// Standalone checkbox bound to a boolean signal.
|
|
52886
|
-
props.defaultChecked = defaultVal;
|
|
52887
|
-
} else {
|
|
52888
|
-
// Checkbox is a group member: defaultVal is the array of
|
|
52889
|
-
// selected item values.
|
|
52890
|
-
const checkboxValue = props.value;
|
|
52891
|
-
props.defaultChecked =
|
|
52892
|
-
Array.isArray(defaultVal) && defaultVal.includes(checkboxValue);
|
|
52893
|
-
}
|
|
52894
|
-
}
|
|
52895
|
-
return;
|
|
52896
|
-
}
|
|
52897
|
-
|
|
52898
|
-
seedDefaultValueFromSignal(props);
|
|
52899
|
-
}
|
|
52900
|
-
|
|
52901
|
-
const currentType = props.type;
|
|
52902
|
-
// Apply min/max/step formatters before anything else — this must run even for
|
|
52903
|
-
// standard HTML types (date, time, etc.) that have no NAVI_TYPE_DEFAULTS entry.
|
|
52904
|
-
const currentTypeMinMaxFormatter = MIN_MAX_FORMATTER_BY_TYPE[currentType];
|
|
52905
|
-
const currentTypeStepFormatter = STEP_FORMATTER_BY_TYPE[currentType];
|
|
52906
|
-
if (currentTypeMinMaxFormatter) {
|
|
52907
|
-
props.min = currentTypeMinMaxFormatter(props.min);
|
|
52908
|
-
props.max = currentTypeMinMaxFormatter(props.max);
|
|
52909
|
-
}
|
|
52910
|
-
if (currentTypeStepFormatter) {
|
|
52911
|
-
props.step = currentTypeStepFormatter(props.step);
|
|
52912
|
-
}
|
|
52913
|
-
|
|
52914
|
-
// For navi_number: choose inputMode based on whether step/min/max suggest decimals.
|
|
52915
|
-
// inputMode="numeric" (integer keyboard) vs "decimal" (keyboard with decimal separator).
|
|
52916
|
-
if (currentType === "navi_number") {
|
|
52917
|
-
if (props.inputMode === undefined) {
|
|
52918
|
-
props.inputMode =
|
|
52919
|
-
hasDecimalPlaces(props.step) ||
|
|
52920
|
-
hasDecimalPlaces(props.min) ||
|
|
52921
|
-
hasDecimalPlaces(props.max)
|
|
52922
|
-
? "decimal"
|
|
52923
|
-
: "numeric";
|
|
52924
|
-
}
|
|
52925
|
-
}
|
|
52926
|
-
|
|
52927
|
-
const { charGuard } = props;
|
|
52928
|
-
if (charGuard) {
|
|
52929
|
-
if (charGuard === true || charGuard === "auto") {
|
|
52930
|
-
// Auto-resolve charGuard from context.
|
|
52931
|
-
let charGuardResolved;
|
|
52932
|
-
const inputMode = props.inputMode;
|
|
52933
|
-
if (inputMode === "numeric") {
|
|
52934
|
-
charGuardResolved = "numeric";
|
|
52935
|
-
} else if (inputMode === "decimal") {
|
|
52936
|
-
charGuardResolved = "decimal";
|
|
52937
|
-
} else if (currentType === "tel") {
|
|
52938
|
-
charGuardResolved = "tel";
|
|
52939
|
-
} else if (currentType === "email") {
|
|
52940
|
-
charGuardResolved = "email";
|
|
52941
|
-
}
|
|
52942
|
-
if (charGuardResolved !== undefined) {
|
|
52943
|
-
props.charGuard = charGuardResolved;
|
|
52944
|
-
}
|
|
52945
|
-
}
|
|
52946
|
-
// charGuard is now resolved: derive inputMode from it if not already set.
|
|
52947
|
-
if (props.inputMode === undefined && props.charGuard) {
|
|
52948
|
-
const autoMode = INPUT_MODE_FROM_CHAR_GUARD[props.charGuard];
|
|
52949
|
-
if (autoMode) {
|
|
52950
|
-
props.inputMode = autoMode;
|
|
52951
|
-
}
|
|
52952
|
-
}
|
|
52953
|
-
// Build pattern from the resolved charGuard (preset name → class, or raw class passthrough).
|
|
52954
|
-
if (props.pattern === undefined && props.charGuard) {
|
|
52955
|
-
const charClass = CHAR_CLASS_PRESETS[props.charGuard] ?? props.charGuard;
|
|
52956
|
-
props.pattern = `${charClass}*`;
|
|
52957
|
-
}
|
|
52958
|
-
}
|
|
52959
|
-
|
|
52960
|
-
// Compute maxLength from max when inputMode is numeric/decimal.
|
|
52961
|
-
// Done here (after inputMode is set) so controller.props has the resolved value.
|
|
52962
|
-
if (props.maxLength === undefined) {
|
|
52963
|
-
if (props.inputMode === "numeric") {
|
|
52964
|
-
const { min, max } = props;
|
|
52965
|
-
if (max === undefined) ; else {
|
|
52966
|
-
const canBeNegative = min === undefined ? max < 0 : min < 0;
|
|
52967
|
-
const signCharCount = canBeNegative ? 1 : 0;
|
|
52968
|
-
const integerDigitCount = String(Math.floor(Math.abs(max))).length;
|
|
52969
|
-
props.maxLength = signCharCount + integerDigitCount;
|
|
52970
|
-
}
|
|
52971
|
-
} else if (props.inputMode === "decimal") {
|
|
52972
|
-
const { min, max, step } = props;
|
|
52973
|
-
if (max === undefined) ; else if (step === undefined) ; else {
|
|
52974
|
-
const canBeNegative = min === undefined ? max < 0 : min < 0;
|
|
52975
|
-
const signCharCount = canBeNegative ? 1 : 0;
|
|
52976
|
-
const integerDigitCount = String(Math.floor(Math.abs(max))).length;
|
|
52977
|
-
const stepStr = String(step);
|
|
52978
|
-
const dotIndex = stepStr.indexOf(".");
|
|
52979
|
-
// integer step + decimal inputMode is an unusual combo, but we stay consistent:
|
|
52980
|
-
// no decimal part in maxLength since valid values are whole numbers anyway
|
|
52981
|
-
const isIntegerStep = dotIndex === -1;
|
|
52982
|
-
const decimalSignCharCount = isIntegerStep ? 0 : 1;
|
|
52983
|
-
const decimalDigitCount = isIntegerStep
|
|
52984
|
-
? 0
|
|
52985
|
-
: stepStr.length - dotIndex - 1;
|
|
52986
|
-
props.maxLength =
|
|
52987
|
-
signCharCount +
|
|
52988
|
-
integerDigitCount +
|
|
52989
|
-
decimalSignCharCount +
|
|
52990
|
-
decimalDigitCount;
|
|
52991
|
-
}
|
|
52992
|
-
}
|
|
52993
|
-
}
|
|
52994
|
-
|
|
52995
|
-
// Resolve maxLengthGuard boolean/auto → the computed maxLength number.
|
|
52996
|
-
if (props.maxLengthGuard === true || props.maxLengthGuard === "auto") {
|
|
52997
|
-
props.maxLengthGuard =
|
|
52998
|
-
typeof props.maxLength === "number" ? props.maxLength : undefined;
|
|
52999
|
-
}
|
|
53000
|
-
|
|
53001
|
-
const currentTypeDefaults = NAVI_TYPE_DEFAULTS[currentType];
|
|
53002
|
-
if (!currentTypeDefaults) {
|
|
53003
|
-
return;
|
|
53004
|
-
}
|
|
53005
|
-
|
|
53006
|
-
for (const key of Object.keys(currentTypeDefaults)) {
|
|
53007
|
-
if (props[key] === undefined) {
|
|
53008
|
-
props[key] = currentTypeDefaults[key];
|
|
52810
|
+
if (lastPointerTypeRef.current === "touch") {
|
|
52811
|
+
return;
|
|
53009
52812
|
}
|
|
53010
|
-
|
|
53011
|
-
|
|
53012
|
-
|
|
53013
|
-
resolveInputProps(props);
|
|
53014
|
-
};
|
|
52813
|
+
e.preventDefault();
|
|
52814
|
+
e.target.select();
|
|
52815
|
+
};
|
|
53015
52816
|
|
|
53016
|
-
|
|
53017
|
-
const INPUT_MODE_FROM_CHAR_GUARD = {
|
|
53018
|
-
numeric: "numeric",
|
|
53019
|
-
pin: "numeric",
|
|
53020
|
-
card: "numeric",
|
|
53021
|
-
tel: "tel",
|
|
53022
|
-
decimal: "decimal",
|
|
52817
|
+
return { onFocus, onMouseDown, onPointerDown };
|
|
53023
52818
|
};
|
|
53024
52819
|
|
|
53025
|
-
|
|
53026
|
-
|
|
53027
|
-
|
|
53028
|
-
|
|
53029
|
-
|
|
53030
|
-
|
|
52820
|
+
/**
|
|
52821
|
+
* Input component for all textual input types.
|
|
52822
|
+
*
|
|
52823
|
+
* Note pour plus tard: un jour on voudra un cas field-sizing: content;
|
|
52824
|
+
*
|
|
52825
|
+
*
|
|
52826
|
+
* Supports:
|
|
52827
|
+
* - text (default)
|
|
52828
|
+
* - password
|
|
52829
|
+
* - hidden
|
|
52830
|
+
* - email
|
|
52831
|
+
* - url
|
|
52832
|
+
* - search
|
|
52833
|
+
* - tel
|
|
52834
|
+
* - etc.
|
|
52835
|
+
*
|
|
52836
|
+
* For non-textual inputs, specialized components will be used:
|
|
52837
|
+
* - <InputCheckbox /> for type="checkbox"
|
|
52838
|
+
* - <InputRadio /> for type="radio"
|
|
52839
|
+
*
|
|
52840
|
+
* Guard props (immediate feedback instead of wait-for-submit):
|
|
52841
|
+
*
|
|
52842
|
+
* - charGuard — restricts which characters can be typed, pasted, or set externally.
|
|
52843
|
+
* Accepts a preset name or a raw regex character class:
|
|
52844
|
+
* "numeric" → digits only, sets inputMode="numeric" + pattern auto
|
|
52845
|
+
* "alpha" → letters only
|
|
52846
|
+
* "alphanumeric" → letters and digits
|
|
52847
|
+
* "uppercase" → uppercase letters only
|
|
52848
|
+
* "tel" → phone chars (digits, +, -, parens, space), sets inputMode="tel"
|
|
52849
|
+
* "card" → credit card (digits and spaces), sets inputMode="numeric"
|
|
52850
|
+
* "hex" → hexadecimal digits
|
|
52851
|
+
* "pin" → numeric PIN, sets inputMode="numeric"
|
|
52852
|
+
* "postal" → postal code (digits, letters, space, hyphen)
|
|
52853
|
+
* "iban" → IBAN (uppercase and digits)
|
|
52854
|
+
* "slug" → URL slug (lowercase, digits, hyphens)
|
|
52855
|
+
* "noEmoji" → anything but an emoji
|
|
52856
|
+
* "[A-Z0-9]" → any custom regex character class, compiled with the `u`
|
|
52857
|
+
* flag: `\p{...}` is available, and an emoji counts as one
|
|
52858
|
+
* character rather than two halves.
|
|
52859
|
+
* inputMode and pattern are auto-derived from the preset when not explicitly set.
|
|
52860
|
+
* The presets come from @jsenv/validity, so the same name names the class a
|
|
52861
|
+
* server checks the value against (see docs/field_validation.md).
|
|
52862
|
+
*
|
|
52863
|
+
* - maxLengthGuard — combines maxLength + overflow guard in one prop.
|
|
52864
|
+
* Blocks keydown when the limit is reached; truncates on paste/set with an info callout.
|
|
52865
|
+
* The maxLength constraint remains active for form validation at submit.
|
|
52866
|
+
* Use plain maxLength (without maxLengthGuard) for submit-only validation.
|
|
52867
|
+
*
|
|
52868
|
+
* Background color:
|
|
52869
|
+
* - backgroundColor="transparent" applies at rest and hover; a focused field
|
|
52870
|
+
* turns solid (--navi-surface-color) so text is not typed over what sits behind.
|
|
52871
|
+
* - variant="discrete" drops background and border at rest; focus brings back
|
|
52872
|
+
* a solid surface. variant="discrete-border" does the same but keeps the border.
|
|
52873
|
+
* - variant="discrete" + backgroundColor: the color applies at rest and hover,
|
|
52874
|
+
* and the field goes transparent while focused.
|
|
52875
|
+
*
|
|
52876
|
+
* variant="text" is the odd one: it renders no <input> at all, just the value
|
|
52877
|
+
* as text — see InputTextualAsText below for what it is for and what it drops.
|
|
52878
|
+
*/
|
|
52879
|
+
|
|
52880
|
+
const InputHeadlessResolver = props => {
|
|
52881
|
+
const Next = useNextResolver();
|
|
52882
|
+
if (props.headless) {
|
|
52883
|
+
return jsx(InputTextualHeadless, {
|
|
52884
|
+
...props
|
|
52885
|
+
});
|
|
53031
52886
|
}
|
|
53032
|
-
if (
|
|
53033
|
-
return
|
|
52887
|
+
if (props.type === "hidden") {
|
|
52888
|
+
return jsx(InputHidden, {
|
|
52889
|
+
...props
|
|
52890
|
+
});
|
|
53034
52891
|
}
|
|
53035
|
-
return
|
|
52892
|
+
return jsx(Next, {
|
|
52893
|
+
...props
|
|
52894
|
+
});
|
|
52895
|
+
};
|
|
52896
|
+
const InputHidden = props => {
|
|
52897
|
+
const [inputRootProps, inputHostProps] = useInputTextualProps(props);
|
|
52898
|
+
return jsx(RealInput, {
|
|
52899
|
+
...inputRootProps,
|
|
52900
|
+
...inputHostProps
|
|
52901
|
+
});
|
|
52902
|
+
};
|
|
52903
|
+
const InputTextualHeadless = props => {
|
|
52904
|
+
const [inputRootProps, inputHostProps] = useInputTextualProps(props);
|
|
52905
|
+
return jsx(RealInput, {
|
|
52906
|
+
"navi-visually-hidden": "",
|
|
52907
|
+
"navi-focus-delegate": "",
|
|
52908
|
+
"aria-hidden": "true",
|
|
52909
|
+
...inputRootProps,
|
|
52910
|
+
...inputHostProps
|
|
52911
|
+
});
|
|
52912
|
+
};
|
|
52913
|
+
const useInputTextualProps = props => {
|
|
52914
|
+
return useControlProps(props, {
|
|
52915
|
+
controlType: "input"
|
|
52916
|
+
});
|
|
53036
52917
|
};
|
|
52918
|
+
const InputTextualUI = props => {
|
|
52919
|
+
installInputCss();
|
|
52920
|
+
const {
|
|
52921
|
+
ui,
|
|
52922
|
+
variant,
|
|
52923
|
+
backgroundColor,
|
|
52924
|
+
width = "maxLength"
|
|
52925
|
+
} = props;
|
|
52926
|
+
const [inputControlRootProps, inputControlHostProps, controlChildrenWrapperProps] = useInputTextualProps(props);
|
|
52927
|
+
const {
|
|
52928
|
+
id,
|
|
52929
|
+
basePseudoState,
|
|
52930
|
+
children
|
|
52931
|
+
} = inputControlHostProps;
|
|
52932
|
+
const {
|
|
52933
|
+
uiStateController
|
|
52934
|
+
} = controlChildrenWrapperProps;
|
|
52935
|
+
const value = uiStateController.uiState;
|
|
52936
|
+
const disabled = basePseudoState[":disabled"];
|
|
52937
|
+
const readOnly = basePseudoState[":read-only"];
|
|
52938
|
+
const loading = basePseudoState[":-navi-loading"];
|
|
52939
|
+
const childrenWithContext = jsx(ControlChildrenWrapper, {
|
|
52940
|
+
...controlChildrenWrapperProps,
|
|
52941
|
+
children: jsx(InputTextualContext.Provider, {
|
|
52942
|
+
value: {
|
|
52943
|
+
id,
|
|
52944
|
+
readOnly,
|
|
52945
|
+
disabled,
|
|
52946
|
+
value
|
|
52947
|
+
},
|
|
52948
|
+
children: children || ui
|
|
52949
|
+
})
|
|
52950
|
+
});
|
|
53037
52951
|
|
|
53038
|
-
|
|
53039
|
-
|
|
53040
|
-
|
|
53041
|
-
|
|
52952
|
+
// meant to end on input
|
|
52953
|
+
// we have to use delete otherwise it could override width: undefined
|
|
52954
|
+
// when remainingProps contains expandX which would try to set width to 100%
|
|
52955
|
+
delete inputControlRootProps.width;
|
|
52956
|
+
if (width === "maxLength") {
|
|
52957
|
+
const widthFromMaxLength = resolveWidthFromMaxLength(inputControlHostProps.maxLength, props.inputMode);
|
|
52958
|
+
if (widthFromMaxLength !== undefined) {
|
|
52959
|
+
inputControlHostProps.width = widthFromMaxLength;
|
|
52960
|
+
}
|
|
52961
|
+
} else if (width === "content") {
|
|
52962
|
+
inputControlHostProps.fieldSizing = "content";
|
|
52963
|
+
} else {
|
|
52964
|
+
inputControlHostProps.width = width;
|
|
53042
52965
|
}
|
|
53043
|
-
|
|
53044
|
-
|
|
53045
|
-
|
|
53046
|
-
|
|
52966
|
+
return jsxs(Box, {
|
|
52967
|
+
as: "span",
|
|
52968
|
+
inline: true,
|
|
52969
|
+
flex: true,
|
|
52970
|
+
baseClassName: "navi_input",
|
|
52971
|
+
...inputControlRootProps,
|
|
52972
|
+
basePseudoState: basePseudoState,
|
|
52973
|
+
ui: undefined,
|
|
52974
|
+
"data-variant": variant || undefined,
|
|
52975
|
+
"data-background": backgroundColor !== undefined && backgroundColor !== "transparent" ? "" : undefined,
|
|
52976
|
+
"data-background-transparent": backgroundColor === "transparent" ? "" : undefined,
|
|
52977
|
+
styleCSSVars: InputStyleCSSVars,
|
|
52978
|
+
pseudoStateSelector: ".navi_control_input",
|
|
52979
|
+
pseudoClasses: InputPseudoClasses,
|
|
52980
|
+
pseudoElements: InputPseudoElements
|
|
52981
|
+
// input may have left/right icons and we want the anchor to target the input element
|
|
52982
|
+
// which is where the interaction can happen
|
|
52983
|
+
,
|
|
52984
|
+
"data-callout-anchor": ".navi_control_input",
|
|
52985
|
+
children: [jsx(LoadingOutline, {
|
|
52986
|
+
loading: loading,
|
|
52987
|
+
color: "var(--loader-color)",
|
|
52988
|
+
inset: -1
|
|
52989
|
+
}), variant === "underline" ? jsxs("span", {
|
|
52990
|
+
className: "navi_input_real_input_wrapper",
|
|
52991
|
+
children: [jsx(RealInput, {
|
|
52992
|
+
...inputControlHostProps
|
|
52993
|
+
}), jsx("span", {
|
|
52994
|
+
className: "navi_input_underline"
|
|
52995
|
+
})]
|
|
52996
|
+
}) : jsx(RealInput, {
|
|
52997
|
+
...inputControlHostProps
|
|
52998
|
+
}), childrenWithContext]
|
|
52999
|
+
});
|
|
53047
53000
|
};
|
|
53048
|
-
|
|
53049
|
-
|
|
53050
|
-
|
|
53051
|
-
|
|
53001
|
+
// How wide a field is when its width is left to what it accepts: a value that
|
|
53002
|
+
// cannot exceed maxLength characters needs no more room than that. Shared with
|
|
53003
|
+
// the text variant, which must land on the same number or the two would not be
|
|
53004
|
+
// the same box.
|
|
53005
|
+
const resolveWidthFromMaxLength = (maxLength, inputMode) => {
|
|
53006
|
+
if (maxLength === undefined) {
|
|
53007
|
+
return undefined;
|
|
53052
53008
|
}
|
|
53053
|
-
|
|
53054
|
-
|
|
53055
|
-
return `${yyyy}-${mm}`;
|
|
53056
|
-
};
|
|
53057
|
-
const toInputWeek = (value) => {
|
|
53058
|
-
const date = normalizeToDate(value);
|
|
53059
|
-
if (!date) {
|
|
53060
|
-
return value;
|
|
53009
|
+
if (inputMode === "numeric") {
|
|
53010
|
+
return `${maxLength}ch`;
|
|
53061
53011
|
}
|
|
53062
|
-
|
|
53063
|
-
const d = new Date(date);
|
|
53064
|
-
d.setHours(0, 0, 0, 0);
|
|
53065
|
-
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
|
53066
|
-
const yearStart = new Date(d.getFullYear(), 0, 4);
|
|
53067
|
-
const week =
|
|
53068
|
-
Math.round(
|
|
53069
|
-
((d - yearStart) / 86400000 - 3 + ((yearStart.getDay() + 6) % 7)) / 7,
|
|
53070
|
-
) + 1;
|
|
53071
|
-
return `${d.getFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
53012
|
+
return `calc(${maxLength} * 1.5ch)`;
|
|
53072
53013
|
};
|
|
53073
|
-
|
|
53074
|
-
|
|
53075
|
-
|
|
53076
|
-
|
|
53014
|
+
|
|
53015
|
+
/**
|
|
53016
|
+
* variant="text" — the value, written where the field would be and taking
|
|
53017
|
+
* exactly its room: same paddings, same font, same line, and the border kept
|
|
53018
|
+
* but made invisible, so a value one only reads and the same value being
|
|
53019
|
+
* edited are one box. What it is for: an information that is sometimes known
|
|
53020
|
+
* (a name already on the profile) and sometimes asked for. Swapping the field
|
|
53021
|
+
* for its text must move nothing under it.
|
|
53022
|
+
*
|
|
53023
|
+
* It is text, and nothing else: no <input>, so nothing to focus, nothing in
|
|
53024
|
+
* the tab order, and nothing sent when the form is submitted — a value the
|
|
53025
|
+
* form must carry goes in an <Input type="hidden"> beside this one. Not a
|
|
53026
|
+
* disabled control either: `disabled`/`aria-disabled` would announce a field
|
|
53027
|
+
* one cannot use, where there is no field at all.
|
|
53028
|
+
*
|
|
53029
|
+
* The field's own props are dropped rather than half-honoured (placeholder,
|
|
53030
|
+
* the guards, the slots): they all describe an edition that does not happen
|
|
53031
|
+
* here. What is kept is what decides the box.
|
|
53032
|
+
*/
|
|
53033
|
+
// Everything a field takes that a text does not: what the value is said with,
|
|
53034
|
+
// what the box is measured from (read below, then dropped too), and all the
|
|
53035
|
+
// rest — the guards, the slots, the constraints — which describe an edition
|
|
53036
|
+
// that does not happen here. What survives is what a Box understands: width,
|
|
53037
|
+
// spacing, colors, className, style.
|
|
53038
|
+
const INPUT_ONLY_PROPS = ["value", "defaultValue", "signal", "id", "maxLength", "inputMode", "width", "variant", "type", "name", "placeholder", "required", "readOnly", "disabled", "loading", "error", "min", "max", "step", "pattern", "autoComplete", "autoCorrect", "spellcheck", "charGuard", "maxLengthGuard", "list", "suggestions", "headless", "fieldSizing", "action", "uiAction", "ui", "children"];
|
|
53039
|
+
const InputTextualAsText = props => {
|
|
53040
|
+
installInputCss();
|
|
53041
|
+
// The id a Field handed down, which is what its Label points at.
|
|
53042
|
+
const controlId = useContext(ControlIdContext);
|
|
53043
|
+
const {
|
|
53044
|
+
value,
|
|
53045
|
+
defaultValue,
|
|
53046
|
+
signal,
|
|
53047
|
+
id,
|
|
53048
|
+
maxLength,
|
|
53049
|
+
inputMode,
|
|
53050
|
+
width = "maxLength"
|
|
53051
|
+
} = props;
|
|
53052
|
+
const valueShown = signal ? signal.value : value ?? defaultValue;
|
|
53053
|
+
const textWidth = width === "maxLength" ? resolveWidthFromMaxLength(maxLength, inputMode) : width === "content" ? undefined : width;
|
|
53054
|
+
const boxProps = {
|
|
53055
|
+
...props
|
|
53056
|
+
};
|
|
53057
|
+
for (const inputOnlyProp of INPUT_ONLY_PROPS) {
|
|
53058
|
+
delete boxProps[inputOnlyProp];
|
|
53077
53059
|
}
|
|
53078
|
-
|
|
53079
|
-
|
|
53080
|
-
|
|
53060
|
+
return jsx(Box, {
|
|
53061
|
+
as: "span",
|
|
53062
|
+
inline: true,
|
|
53063
|
+
flex: true,
|
|
53064
|
+
baseClassName: "navi_input",
|
|
53065
|
+
"data-variant": "text",
|
|
53066
|
+
styleCSSVars: InputStyleCSSVars,
|
|
53067
|
+
...boxProps,
|
|
53068
|
+
children: jsx(Box, {
|
|
53069
|
+
as: "span",
|
|
53070
|
+
baseClassName: "navi_input_text",
|
|
53071
|
+
id: id || controlId,
|
|
53072
|
+
width: textWidth,
|
|
53073
|
+
children: jsx("span", {
|
|
53074
|
+
className: "navi_input_text_value",
|
|
53075
|
+
children: valueShown
|
|
53076
|
+
})
|
|
53077
|
+
})
|
|
53078
|
+
});
|
|
53081
53079
|
};
|
|
53082
|
-
const
|
|
53083
|
-
const
|
|
53084
|
-
if (
|
|
53085
|
-
return
|
|
53080
|
+
const InputTextualAsTextResolver = props => {
|
|
53081
|
+
const Next = useNextResolver();
|
|
53082
|
+
if (props.variant === "text") {
|
|
53083
|
+
return jsx(InputTextualAsText, {
|
|
53084
|
+
...props
|
|
53085
|
+
});
|
|
53086
53086
|
}
|
|
53087
|
-
|
|
53088
|
-
|
|
53089
|
-
|
|
53090
|
-
const hh = String(date.getHours()).padStart(2, "0");
|
|
53091
|
-
const min = String(date.getMinutes()).padStart(2, "0");
|
|
53092
|
-
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
|
|
53087
|
+
return jsx(Next, {
|
|
53088
|
+
...props
|
|
53089
|
+
});
|
|
53093
53090
|
};
|
|
53094
|
-
|
|
53095
|
-
const
|
|
53096
|
-
|
|
53097
|
-
|
|
53098
|
-
|
|
53099
|
-
|
|
53100
|
-
|
|
53101
|
-
"datetime": toInputDatetime,
|
|
53091
|
+
const InputTextualFirstResolver = props => {
|
|
53092
|
+
const Next = useNextResolver();
|
|
53093
|
+
const defaultRef = useRef(null);
|
|
53094
|
+
props.ref = props.ref || defaultRef;
|
|
53095
|
+
return jsx(Next, {
|
|
53096
|
+
...props
|
|
53097
|
+
});
|
|
53102
53098
|
};
|
|
53103
|
-
const
|
|
53104
|
-
|
|
53105
|
-
|
|
53106
|
-
|
|
53099
|
+
const InputTextual = /*#__PURE__*/createComponentResolver([InputTextualAsTextResolver, InputTextualFirstResolver, InputWithListResolver, InputWithSuggestionsResolver, InputTypeResolver, InputModeResolver, InputHeadlessResolver, InputTextualUI]);
|
|
53100
|
+
const RealInput = ({
|
|
53101
|
+
maxLength,
|
|
53102
|
+
...domProps
|
|
53103
|
+
}) => {
|
|
53104
|
+
const autoSelectReadOnlyProps = useAutoSelectReadOnly(domProps);
|
|
53105
|
+
return jsx(Box, {
|
|
53106
|
+
...domProps,
|
|
53107
|
+
as: "input",
|
|
53108
|
+
baseClassName: "navi_control_input",
|
|
53109
|
+
...autoSelectReadOnlyProps,
|
|
53110
|
+
// Never set native maxLength — our guard handles it. Omitting it entirely
|
|
53111
|
+
// avoids a Preact quirk: setting maxLength={undefined} on a fresh DOM element
|
|
53112
|
+
// (e.g. after a Suspense remount) causes Preact to run `el.maxLength = ""`
|
|
53113
|
+
// which coerces to 0 (Number("") = 0), capping the input at 0 characters.
|
|
53114
|
+
// see https://github.com/preactjs/preact/issues/2677
|
|
53115
|
+
// The JS value stays accessible via the navi-max-length attribute and via
|
|
53116
|
+
// inputControlHostProps (which the validation system reads directly).
|
|
53117
|
+
"navi-max-length": maxLength
|
|
53118
|
+
});
|
|
53107
53119
|
};
|
|
53108
53120
|
|
|
53109
|
-
|
|
53110
|
-
|
|
53111
|
-
|
|
53121
|
+
// Shared with textarea.jsx: a textarea is styled as a .navi_input box, so the
|
|
53122
|
+
// two read the same style props and pseudo states.
|
|
53123
|
+
const InputStyleCSSVars = {
|
|
53124
|
+
"slotSpacing": ["--slot-spacing", "margin"],
|
|
53125
|
+
"outlineWidth": "--outline-width",
|
|
53126
|
+
"borderWidth": "--border-width",
|
|
53127
|
+
"borderRadius": "--border-radius",
|
|
53128
|
+
"padding": "--padding",
|
|
53129
|
+
"paddingX": "--padding-x",
|
|
53130
|
+
"paddingY": "--padding-y",
|
|
53131
|
+
"paddingTop": "--padding-top",
|
|
53132
|
+
"paddingRight": "--padding-right",
|
|
53133
|
+
"paddingBottom": "--padding-bottom",
|
|
53134
|
+
"paddingLeft": "--padding-left",
|
|
53135
|
+
"background": "--background",
|
|
53136
|
+
"backgroundColor": "--background-color",
|
|
53137
|
+
"borderColor": "--border-color",
|
|
53138
|
+
"color": "--color",
|
|
53139
|
+
"fontSize": "--font-size",
|
|
53140
|
+
":hover": {
|
|
53141
|
+
backgroundColor: "--background-color-hover",
|
|
53142
|
+
borderColor: "--border-color-hover",
|
|
53143
|
+
color: "--color-hover"
|
|
53144
|
+
},
|
|
53145
|
+
":focus": {
|
|
53146
|
+
backgroundColor: "--background-color-focus",
|
|
53147
|
+
borderColor: "--border-color-focus"
|
|
53148
|
+
},
|
|
53149
|
+
":active": {
|
|
53150
|
+
backgroundColor: "--background-color-active",
|
|
53151
|
+
borderColor: "--border-color-active"
|
|
53152
|
+
},
|
|
53153
|
+
":read-only": {
|
|
53154
|
+
backgroundColor: "--background-color-readonly",
|
|
53155
|
+
borderColor: "--border-color-readonly",
|
|
53156
|
+
color: "--color-readonly"
|
|
53157
|
+
},
|
|
53158
|
+
":disabled": {
|
|
53159
|
+
backgroundColor: "--background-color-disabled",
|
|
53160
|
+
borderColor: "--border-color-disabled",
|
|
53161
|
+
color: "--color-disabled"
|
|
53112
53162
|
}
|
|
53113
|
-
const num = Number(value);
|
|
53114
|
-
return !isNaN(num) && !Number.isInteger(num);
|
|
53115
53163
|
};
|
|
53164
|
+
const InputPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading", ":-navi-has-value"];
|
|
53165
|
+
const InputPseudoElements = ["::-navi-loader"];
|
|
53116
53166
|
|
|
53117
53167
|
const Input = props => {
|
|
53118
53168
|
resolveInputProps(props);
|
|
@@ -65995,10 +66045,11 @@ const ItemTransitionContext = createContext(false);
|
|
|
65995
66045
|
// that must reserve room for what is not rendered.
|
|
65996
66046
|
const ListVirtualContext = createContext(null);
|
|
65997
66047
|
// Set around each row a run of items renders (see ListItems): which row of the
|
|
65998
|
-
// collection it is,
|
|
65999
|
-
//
|
|
66000
|
-
//
|
|
66001
|
-
//
|
|
66048
|
+
// collection it is, where it stands among the rows the list holds, and the
|
|
66049
|
+
// run's own account of which rows mount (see createRunRows) — what the row
|
|
66050
|
+
// draws its separator from. Carried by context rather than injected into
|
|
66051
|
+
// whatever vnode renderItem returned, so that returning a component of one's
|
|
66052
|
+
// own — instead of a bare <List.Item> — works the same way.
|
|
66002
66053
|
const ListRowContext = createContext(null);
|
|
66003
66054
|
// The slot a child of the list stands in, by id (see ListDeclaredChildren). A
|
|
66004
66055
|
// row takes its place in the collection by slot: the place is then the list's
|
|
@@ -68741,22 +68792,12 @@ const ListItemRowResolver = props => {
|
|
|
68741
68792
|
...props
|
|
68742
68793
|
});
|
|
68743
68794
|
}
|
|
68744
|
-
// eslint-disable-next-line no-unused-vars
|
|
68745
|
-
const {
|
|
68746
|
-
id,
|
|
68747
|
-
index,
|
|
68748
|
-
item,
|
|
68749
|
-
rowMinHeight,
|
|
68750
|
-
rowMinWidth,
|
|
68751
|
-
...rowProps
|
|
68752
|
-
} = row;
|
|
68753
68795
|
return jsx(Next, {
|
|
68754
|
-
...rowProps,
|
|
68755
68796
|
...props,
|
|
68756
68797
|
id: props.id || row.id,
|
|
68757
68798
|
index: row.index,
|
|
68758
|
-
minHeight: props.minHeight === undefined ? rowMinHeight : props.minHeight,
|
|
68759
|
-
minWidth: props.minWidth === undefined ? rowMinWidth : props.minWidth
|
|
68799
|
+
minHeight: props.minHeight === undefined ? row.rowMinHeight : props.minHeight,
|
|
68800
|
+
minWidth: props.minWidth === undefined ? row.rowMinWidth : props.minWidth
|
|
68760
68801
|
});
|
|
68761
68802
|
};
|
|
68762
68803
|
const ListItemPresentationResolver = props => {
|
|
@@ -68834,8 +68875,8 @@ const ListItemUI = props => {
|
|
|
68834
68875
|
const virtual = useContext(ListVirtualContext);
|
|
68835
68876
|
const searchNoMatchMode = useContext(SearchNoMatchModeContext);
|
|
68836
68877
|
// The run this row belongs to, when it comes from one (see ListItems): it
|
|
68837
|
-
// registered the row
|
|
68838
|
-
//
|
|
68878
|
+
// registered the row and decided it is inside the render window. Whether
|
|
68879
|
+
// the row mounts is decided here, and told back to the run.
|
|
68839
68880
|
const row = useContext(ListRowContext);
|
|
68840
68881
|
const slotId = useContext(ListSlotContext);
|
|
68841
68882
|
// There is no standalone match/matchScore/highlight prop — participation
|
|
@@ -68867,16 +68908,24 @@ const ListItemUI = props => {
|
|
|
68867
68908
|
// name of this very component (idDefault, not the row's id): two components
|
|
68868
68909
|
// may stand for the same row for a moment, one leaving as the other arrives,
|
|
68869
68910
|
// and the one leaving must give back its own place, not the newcomer's.
|
|
68870
|
-
if (
|
|
68911
|
+
if (row) {
|
|
68871
68912
|
if (props.filtered) {
|
|
68872
|
-
|
|
68913
|
+
row.run.unmount(idDefault);
|
|
68873
68914
|
} else {
|
|
68874
|
-
|
|
68915
|
+
row.run.mount(idDefault, props.index, row.groupKey);
|
|
68875
68916
|
}
|
|
68917
|
+
} else if (props.filtered) {
|
|
68918
|
+
virtual.drop(idDefault);
|
|
68919
|
+
} else {
|
|
68920
|
+
props.index = virtual.take(idDefault, 1, slotId);
|
|
68876
68921
|
}
|
|
68877
68922
|
useLayoutEffect(() => {
|
|
68878
68923
|
return () => {
|
|
68879
|
-
|
|
68924
|
+
if (row) {
|
|
68925
|
+
row.run.unmount(idDefault);
|
|
68926
|
+
} else {
|
|
68927
|
+
virtual.drop(idDefault);
|
|
68928
|
+
}
|
|
68880
68929
|
};
|
|
68881
68930
|
}, []);
|
|
68882
68931
|
// Every row that is drawn registers itself, whether it was declared one by
|
|
@@ -68890,47 +68939,43 @@ const ListItemUI = props => {
|
|
|
68890
68939
|
if (props.filtered) {
|
|
68891
68940
|
return null;
|
|
68892
68941
|
}
|
|
68893
|
-
// html-hidden items: excluded from virtual scroll accounting but always in DOM
|
|
68894
|
-
if (props.hidden) {
|
|
68895
|
-
// Its separator stays too, and stays invisible with it: the point of
|
|
68896
|
-
// keeping a row that matches nothing is that nothing moves, and a divider
|
|
68897
|
-
// that leaves takes its own height away.
|
|
68898
|
-
if (!separator || props.index === 0) {
|
|
68899
|
-
return jsx(ListItemReal, {
|
|
68900
|
-
...props
|
|
68901
|
-
});
|
|
68902
|
-
}
|
|
68903
|
-
return jsxs(Fragment, {
|
|
68904
|
-
children: [cloneElement(resolveSeparatorVnode(separator, props.index - 1), {
|
|
68905
|
-
style: VISIBILITY_HIDDEN_STYLE
|
|
68906
|
-
}), jsx(ListItemReal, {
|
|
68907
|
-
...props
|
|
68908
|
-
})]
|
|
68909
|
-
});
|
|
68910
|
-
}
|
|
68911
|
-
if (row) {
|
|
68912
|
-
return jsx(ListItemReal, {
|
|
68913
|
-
...props
|
|
68914
|
-
});
|
|
68915
|
-
}
|
|
68916
|
-
const index = props.index;
|
|
68917
68942
|
const listItemVnode = jsx(ListItemReal, {
|
|
68918
68943
|
...props
|
|
68919
68944
|
});
|
|
68920
|
-
|
|
68921
|
-
// out (virtual.take above), not by the tracker's visibleIndex: during a
|
|
68922
|
-
// reorder render pass (items resorted by search score) the other items still
|
|
68923
|
-
// carry stale keyToExplicitOrder values, the binary search reads them, no
|
|
68924
|
-
// item comes out at 0 and a spurious separator appears at the top. Inside a
|
|
68925
|
-
// group, each group has its own tracker and its items do not reorder, so
|
|
68926
|
-
// groupVisibleIndex is reliable.
|
|
68927
|
-
const isFirstInList = groupVisibleIndex === null ? index === 0 : groupVisibleIndex === 0;
|
|
68928
|
-
if (!separator || isFirstInList) {
|
|
68945
|
+
if (!separator) {
|
|
68929
68946
|
return listItemVnode;
|
|
68930
68947
|
}
|
|
68931
|
-
//
|
|
68932
|
-
|
|
68933
|
-
|
|
68948
|
+
// The separator a row wears is the one at the gap above it, so the first row
|
|
68949
|
+
// that mounts wears none. "Am I first?" is answered by whoever handed out
|
|
68950
|
+
// the place — the run for its rows, the list's virtual for a declared one
|
|
68951
|
+
// (virtual.take above) — not by the tracker's visibleIndex: during a reorder
|
|
68952
|
+
// render pass (items resorted by search score) the other items still carry
|
|
68953
|
+
// stale keyToExplicitOrder values, the binary search reads them, no item
|
|
68954
|
+
// comes out at 0 and a spurious separator appears at the top. Inside a
|
|
68955
|
+
// declared group, each group has its own tracker and its items do not
|
|
68956
|
+
// reorder, so groupVisibleIndex is reliable there.
|
|
68957
|
+
let isFirst;
|
|
68958
|
+
if (row) {
|
|
68959
|
+
isFirst = row.run.isFirst(props.index, row.groupKey);
|
|
68960
|
+
} else if (groupVisibleIndex === null || props.hidden) {
|
|
68961
|
+
isFirst = props.index === 0;
|
|
68962
|
+
} else {
|
|
68963
|
+
isFirst = groupVisibleIndex === 0;
|
|
68964
|
+
}
|
|
68965
|
+
if (isFirst) {
|
|
68966
|
+
return listItemVnode;
|
|
68967
|
+
}
|
|
68968
|
+
// The gap index, only used as the function-form argument.
|
|
68969
|
+
const gapIndex = row || groupVisibleIndex === null || props.hidden ? props.index - 1 : groupVisibleIndex - 1;
|
|
68970
|
+
let separatorVnode = resolveSeparatorVnode(separator, gapIndex);
|
|
68971
|
+
if (props.hidden) {
|
|
68972
|
+
// A row kept in the DOM but hidden keeps its separator, hidden with it:
|
|
68973
|
+
// the point of keeping a row that matches nothing is that nothing moves,
|
|
68974
|
+
// and a divider that leaves takes its own height away.
|
|
68975
|
+
separatorVnode = cloneElement(separatorVnode, {
|
|
68976
|
+
style: VISIBILITY_HIDDEN_STYLE
|
|
68977
|
+
});
|
|
68978
|
+
}
|
|
68934
68979
|
return jsxs(Fragment, {
|
|
68935
68980
|
children: [separatorVnode, listItemVnode]
|
|
68936
68981
|
});
|
|
@@ -69552,6 +69597,101 @@ const sameSlotIds = (left, right) => {
|
|
|
69552
69597
|
return true;
|
|
69553
69598
|
};
|
|
69554
69599
|
|
|
69600
|
+
// Which of a run's rows mount, and which of them comes first. A run draws
|
|
69601
|
+
// every row of its window, and only the row itself knows, once it renders,
|
|
69602
|
+
// that it renders nothing (filtered out by a search, see ListItemUI). The
|
|
69603
|
+
// separator a row wears is the one at the gap above it, so the first row that
|
|
69604
|
+
// mounts wears none — and "first" is read off the rows that mount, not off
|
|
69605
|
+
// the collection. Rows say so as they render, in order, and the answer is a
|
|
69606
|
+
// signal: a row rendered from a kept vnode is rendered again when the row
|
|
69607
|
+
// before it leaves or comes back. Grouped rows are counted per group, the gap
|
|
69608
|
+
// above a group's first row being the group wrapper's own.
|
|
69609
|
+
const createRunRows = () => {
|
|
69610
|
+
// rowId → { index, groupKey }
|
|
69611
|
+
const rowById = new Map();
|
|
69612
|
+
// groupKey (undefined outside groups) → the index of the group's first
|
|
69613
|
+
// mounted row, -1 when none.
|
|
69614
|
+
const firstSignalByGroup = new Map();
|
|
69615
|
+
// Where the window starts: a run cut by the window has rows above its first
|
|
69616
|
+
// drawn one, and so does a run standing after declared rows.
|
|
69617
|
+
const windowFromSignal = signal(0);
|
|
69618
|
+
// Groups whose first row left: recounted on the next ask, or at the end of
|
|
69619
|
+
// the frame, whichever comes first.
|
|
69620
|
+
const staleGroupKeys = new Set();
|
|
69621
|
+
const firstSignalOf = groupKey => {
|
|
69622
|
+
let firstSignal = firstSignalByGroup.get(groupKey);
|
|
69623
|
+
if (!firstSignal) {
|
|
69624
|
+
firstSignal = signal(-1);
|
|
69625
|
+
firstSignalByGroup.set(groupKey, firstSignal);
|
|
69626
|
+
}
|
|
69627
|
+
return firstSignal;
|
|
69628
|
+
};
|
|
69629
|
+
const refresh = groupKey => {
|
|
69630
|
+
staleGroupKeys.delete(groupKey);
|
|
69631
|
+
let first = -1;
|
|
69632
|
+
for (const row of rowById.values()) {
|
|
69633
|
+
if (row.groupKey === groupKey && (first === -1 || row.index < first)) {
|
|
69634
|
+
first = row.index;
|
|
69635
|
+
}
|
|
69636
|
+
}
|
|
69637
|
+
firstSignalOf(groupKey).value = first;
|
|
69638
|
+
};
|
|
69639
|
+
const leave = row => {
|
|
69640
|
+
if (firstSignalOf(row.groupKey).peek() !== row.index) {
|
|
69641
|
+
return;
|
|
69642
|
+
}
|
|
69643
|
+
staleGroupKeys.add(row.groupKey);
|
|
69644
|
+
queueMicrotask(() => {
|
|
69645
|
+
if (staleGroupKeys.has(row.groupKey)) {
|
|
69646
|
+
refresh(row.groupKey);
|
|
69647
|
+
}
|
|
69648
|
+
});
|
|
69649
|
+
};
|
|
69650
|
+
return {
|
|
69651
|
+
setWindowFrom: windowFrom => {
|
|
69652
|
+
windowFromSignal.value = windowFrom;
|
|
69653
|
+
},
|
|
69654
|
+
mount: (rowId, index, groupKey) => {
|
|
69655
|
+
const row = rowById.get(rowId);
|
|
69656
|
+
if (row) {
|
|
69657
|
+
if (row.index === index && row.groupKey === groupKey) {
|
|
69658
|
+
return;
|
|
69659
|
+
}
|
|
69660
|
+
leave(row);
|
|
69661
|
+
row.index = index;
|
|
69662
|
+
row.groupKey = groupKey;
|
|
69663
|
+
} else {
|
|
69664
|
+
rowById.set(rowId, {
|
|
69665
|
+
index,
|
|
69666
|
+
groupKey
|
|
69667
|
+
});
|
|
69668
|
+
}
|
|
69669
|
+
const firstSignal = firstSignalOf(groupKey);
|
|
69670
|
+
const first = firstSignal.peek();
|
|
69671
|
+
if (first === -1 || index < first) {
|
|
69672
|
+
firstSignal.value = index;
|
|
69673
|
+
}
|
|
69674
|
+
},
|
|
69675
|
+
unmount: rowId => {
|
|
69676
|
+
const row = rowById.get(rowId);
|
|
69677
|
+
if (!row) {
|
|
69678
|
+
return;
|
|
69679
|
+
}
|
|
69680
|
+
rowById.delete(rowId);
|
|
69681
|
+
leave(row);
|
|
69682
|
+
},
|
|
69683
|
+
isFirst: (index, groupKey) => {
|
|
69684
|
+
if (staleGroupKeys.has(groupKey)) {
|
|
69685
|
+
refresh(groupKey);
|
|
69686
|
+
}
|
|
69687
|
+
if (firstSignalOf(groupKey).value !== index) {
|
|
69688
|
+
return false;
|
|
69689
|
+
}
|
|
69690
|
+
return groupKey !== undefined || windowFromSignal.value === 0;
|
|
69691
|
+
}
|
|
69692
|
+
};
|
|
69693
|
+
};
|
|
69694
|
+
|
|
69555
69695
|
// The walk that gives the list's children their places: a slot for each of
|
|
69556
69696
|
// them, declared to the list's virtual all at once before any child renders,
|
|
69557
69697
|
// and handed to the child through a provider of its own — which is what lets
|
|
@@ -69736,6 +69876,11 @@ const ListItems = ({
|
|
|
69736
69876
|
const slotId = useContext(ListSlotContext);
|
|
69737
69877
|
const renderWindow = useContext(RenderWindowContext);
|
|
69738
69878
|
const separator = useContext(SeparatorContext);
|
|
69879
|
+
const runRowsRef = useRef(null);
|
|
69880
|
+
if (!runRowsRef.current) {
|
|
69881
|
+
runRowsRef.current = createRunRows();
|
|
69882
|
+
}
|
|
69883
|
+
const runRows = runRowsRef.current;
|
|
69739
69884
|
// The vnode drawn for a row, kept by item: a run rendering again (its window
|
|
69740
69885
|
// moving, its first paint's budget giving way to the full one) hands preact
|
|
69741
69886
|
// the same vnode for a row that has not changed, and preact leaves that
|
|
@@ -69744,10 +69889,9 @@ const ListItems = ({
|
|
|
69744
69889
|
// row at the same index, in the same refreshing state: everything the
|
|
69745
69890
|
// function is given.
|
|
69746
69891
|
const rowVnodesRef = useRef(null);
|
|
69747
|
-
if (!rowVnodesRef.current || rowVnodesRef.current.renderItem !== renderItem
|
|
69892
|
+
if (!rowVnodesRef.current || rowVnodesRef.current.renderItem !== renderItem) {
|
|
69748
69893
|
rowVnodesRef.current = {
|
|
69749
69894
|
renderItem,
|
|
69750
|
-
separator,
|
|
69751
69895
|
byItem: new Map()
|
|
69752
69896
|
};
|
|
69753
69897
|
}
|
|
@@ -69796,6 +69940,7 @@ const ListItems = ({
|
|
|
69796
69940
|
const windowFrom = renderWindow.start > runStart ? renderWindow.start : runStart;
|
|
69797
69941
|
const windowTo = renderWindow.end < runEnd ? renderWindow.end : runEnd;
|
|
69798
69942
|
store.forget(rankOf(windowFrom), rankOf(windowTo));
|
|
69943
|
+
runRows.setWindowFrom(windowFrom);
|
|
69799
69944
|
|
|
69800
69945
|
// The row answers to its own id when the item carries one — that is what
|
|
69801
69946
|
// addresses it from outside (--navi-select, --navi-scroll, startAt) — and
|
|
@@ -69919,13 +70064,9 @@ const ListItems = ({
|
|
|
69919
70064
|
}, `${ownerId}_group_${group.key}`));
|
|
69920
70065
|
group = null;
|
|
69921
70066
|
};
|
|
69922
|
-
// Which group a row belongs to, or undefined when it belongs to none.
|
|
69923
|
-
// before the row is pushed as well as while pushing it: a row opening a
|
|
69924
|
-
// group is the one row that must not wear a separator (see below).
|
|
70067
|
+
// Which group a row belongs to, or undefined when it belongs to none.
|
|
69925
70068
|
const groupKeyOf = (item, rowIndex) => groupBy && item !== undefined ? groupBy(item, rowIndex) : undefined;
|
|
69926
|
-
const
|
|
69927
|
-
const pushRow = (rowNode, item, rowIndex) => {
|
|
69928
|
-
const groupKey = groupKeyOf(item, rowIndex);
|
|
70069
|
+
const pushRow = (rowNode, item, rowIndex, groupKey) => {
|
|
69929
70070
|
if (groupKey === undefined) {
|
|
69930
70071
|
closeGroup();
|
|
69931
70072
|
rows.push(rowNode);
|
|
@@ -69980,78 +70121,72 @@ const ListItems = ({
|
|
|
69980
70121
|
}
|
|
69981
70122
|
const item = getItemAt(rowIndex);
|
|
69982
70123
|
const key = item === undefined ? `${ownerId}_skeleton_${rowIndex}` : idOf(item, rowIndex);
|
|
70124
|
+
const groupKey = groupKeyOf(item, rowIndex);
|
|
70125
|
+
if (item === undefined) {
|
|
70126
|
+
// A row on its way never reaches ListItemUI (see ListItemSkeletonResolver):
|
|
70127
|
+
// it is stood among the rows that mount, and given its separator, here.
|
|
70128
|
+
let rowVnode;
|
|
70129
|
+
if (renderRowSkeleton === false) {
|
|
70130
|
+
// The row must still take its room: without it the rows below would
|
|
70131
|
+
// climb up and slide back down as the answer arrives.
|
|
70132
|
+
rowVnode = jsx(ListItem, {
|
|
70133
|
+
skeleton: true,
|
|
70134
|
+
style: VISIBILITY_HIDDEN_STYLE
|
|
70135
|
+
});
|
|
70136
|
+
} else if (renderRowSkeleton) {
|
|
70137
|
+
rowVnode = renderRowSkeleton(rowIndex);
|
|
70138
|
+
} else {
|
|
70139
|
+
rowVnode = jsx(ListItem, {
|
|
70140
|
+
skeleton: true
|
|
70141
|
+
});
|
|
70142
|
+
}
|
|
70143
|
+
if (rowVnode) {
|
|
70144
|
+
pushRow(jsx(ListRunSkeletonRow, {
|
|
70145
|
+
run: runRows,
|
|
70146
|
+
row: {
|
|
70147
|
+
id: key,
|
|
70148
|
+
index: rowIndex,
|
|
70149
|
+
...getSkeletonRow()
|
|
70150
|
+
},
|
|
70151
|
+
groupKey: groupKey,
|
|
70152
|
+
separator: separator,
|
|
70153
|
+
children: rowVnode
|
|
70154
|
+
}, key), item, rowIndex, groupKey);
|
|
70155
|
+
}
|
|
70156
|
+
rowIndex++;
|
|
70157
|
+
continue;
|
|
70158
|
+
}
|
|
69983
70159
|
let rowVnode;
|
|
69984
70160
|
let rowContextValue;
|
|
69985
|
-
|
|
69986
|
-
if (
|
|
69987
|
-
|
|
69988
|
-
|
|
69989
|
-
rowVnode = rowVnodeKept.vnode;
|
|
69990
|
-
rowContextValue = rowVnodeKept.rowContextValue;
|
|
69991
|
-
rowKept = rowVnodeKept;
|
|
69992
|
-
} else {
|
|
69993
|
-
rowVnode = renderItem(item, rowIndex, renderItemState);
|
|
69994
|
-
// Kept with the vnode, for the same reason: a context value that is a
|
|
69995
|
-
// fresh object on every render forces every consumer of it to render,
|
|
69996
|
-
// which is the row's own chain — the vnode handed back unchanged would
|
|
69997
|
-
// then buy nothing.
|
|
69998
|
-
rowContextValue = {
|
|
69999
|
-
id: key,
|
|
70000
|
-
index: rowIndex,
|
|
70001
|
-
item
|
|
70002
|
-
};
|
|
70003
|
-
rowKept = {
|
|
70004
|
-
vnode: rowVnode,
|
|
70005
|
-
rowContextValue,
|
|
70006
|
-
rowIndex,
|
|
70007
|
-
refreshing: renderItemState.refreshing,
|
|
70008
|
-
separatorVnode: null
|
|
70009
|
-
};
|
|
70010
|
-
rowVnodesByItem.set(item, rowKept);
|
|
70011
|
-
}
|
|
70012
|
-
} else if (renderRowSkeleton === false) {
|
|
70013
|
-
// The row must still take its room: without it the rows below would
|
|
70014
|
-
// climb up and slide back down as the answer arrives.
|
|
70015
|
-
rowVnode = jsx(ListItem, {
|
|
70016
|
-
skeleton: true,
|
|
70017
|
-
style: VISIBILITY_HIDDEN_STYLE
|
|
70018
|
-
});
|
|
70019
|
-
} else if (renderRowSkeleton) {
|
|
70020
|
-
rowVnode = renderRowSkeleton(rowIndex);
|
|
70161
|
+
const rowVnodeKept = rowVnodesByItem.get(item);
|
|
70162
|
+
if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing && rowVnodeKept.rowContextValue.groupKey === groupKey) {
|
|
70163
|
+
rowVnode = rowVnodeKept.vnode;
|
|
70164
|
+
rowContextValue = rowVnodeKept.rowContextValue;
|
|
70021
70165
|
} else {
|
|
70022
|
-
rowVnode =
|
|
70023
|
-
|
|
70166
|
+
rowVnode = renderItem(item, rowIndex, renderItemState);
|
|
70167
|
+
// Kept with the vnode, for the same reason: a context value that is a
|
|
70168
|
+
// fresh object on every render forces every consumer of it to render,
|
|
70169
|
+
// which is the row's own chain — the vnode handed back unchanged would
|
|
70170
|
+
// then buy nothing.
|
|
70171
|
+
rowContextValue = {
|
|
70172
|
+
id: key,
|
|
70173
|
+
index: rowIndex,
|
|
70174
|
+
item,
|
|
70175
|
+
run: runRows,
|
|
70176
|
+
groupKey
|
|
70177
|
+
};
|
|
70178
|
+
rowVnodesByItem.set(item, {
|
|
70179
|
+
vnode: rowVnode,
|
|
70180
|
+
rowContextValue,
|
|
70181
|
+
rowIndex,
|
|
70182
|
+
refreshing: renderItemState.refreshing
|
|
70024
70183
|
});
|
|
70025
70184
|
}
|
|
70026
70185
|
if (rowVnode) {
|
|
70027
|
-
// The first row of a group wears no separator: the gap it sits at is the
|
|
70028
|
-
// one between two groups, and that gap is the group wrapper's own — it
|
|
70029
|
-
// is a row of the list like any other and draws its separator itself
|
|
70030
|
-
// (see ListItemUI). Drawn here it would land inside the group instead,
|
|
70031
|
-
// as a hairline under the label.
|
|
70032
|
-
const drawSeparator = separator && rowIndex > 0 && !opensGroup(groupKeyOf(item, rowIndex));
|
|
70033
|
-
if (drawSeparator) {
|
|
70034
|
-
// Kept with the row too: a separator built again is a separator
|
|
70035
|
-
// rendered again.
|
|
70036
|
-
let separatorVnode = rowKept ? rowKept.separatorVnode : null;
|
|
70037
|
-
if (!separatorVnode) {
|
|
70038
|
-
separatorVnode = cloneElement(resolveSeparatorVnode(separator, rowIndex - 1), {
|
|
70039
|
-
key: `${key}_separator`
|
|
70040
|
-
});
|
|
70041
|
-
if (rowKept) {
|
|
70042
|
-
rowKept.separatorVnode = separatorVnode;
|
|
70043
|
-
}
|
|
70044
|
-
}
|
|
70045
|
-
pushRow(separatorVnode, item, rowIndex);
|
|
70046
|
-
}
|
|
70047
70186
|
pushRow(jsx(ListRowContext.Provider, {
|
|
70048
|
-
value:
|
|
70049
|
-
id: key,
|
|
70050
|
-
index: rowIndex,
|
|
70051
|
-
...getSkeletonRow()
|
|
70052
|
-
} : rowContextValue,
|
|
70187
|
+
value: rowContextValue,
|
|
70053
70188
|
children: rowVnode
|
|
70054
|
-
}, key), item, rowIndex);
|
|
70189
|
+
}, key), item, rowIndex, groupKey);
|
|
70055
70190
|
}
|
|
70056
70191
|
rowIndex++;
|
|
70057
70192
|
}
|
|
@@ -70065,6 +70200,35 @@ const ListItems = ({
|
|
|
70065
70200
|
return rows;
|
|
70066
70201
|
};
|
|
70067
70202
|
|
|
70203
|
+
// A run's row that has not arrived, standing where the real one will: it
|
|
70204
|
+
// mounts like any row (see createRunRows) and wears the separator of the gap
|
|
70205
|
+
// above it, the way a real row does in ListItemUI.
|
|
70206
|
+
const ListRunSkeletonRow = ({
|
|
70207
|
+
run,
|
|
70208
|
+
row,
|
|
70209
|
+
groupKey,
|
|
70210
|
+
separator,
|
|
70211
|
+
children
|
|
70212
|
+
}) => {
|
|
70213
|
+
const rowId = useId();
|
|
70214
|
+
run.mount(rowId, row.index, groupKey);
|
|
70215
|
+
useLayoutEffect(() => {
|
|
70216
|
+
return () => {
|
|
70217
|
+
run.unmount(rowId);
|
|
70218
|
+
};
|
|
70219
|
+
}, []);
|
|
70220
|
+
const rowVnode = jsx(ListRowContext.Provider, {
|
|
70221
|
+
value: row,
|
|
70222
|
+
children: children
|
|
70223
|
+
});
|
|
70224
|
+
if (!separator || run.isFirst(row.index, groupKey)) {
|
|
70225
|
+
return rowVnode;
|
|
70226
|
+
}
|
|
70227
|
+
return jsxs(Fragment, {
|
|
70228
|
+
children: [resolveSeparatorVnode(separator, row.index - 1), rowVnode]
|
|
70229
|
+
});
|
|
70230
|
+
};
|
|
70231
|
+
|
|
70068
70232
|
// What is drawn where rows were asked for and never came: the sentence and the
|
|
70069
70233
|
// way out, in the row itself — the rest of the list is fine, so replacing all
|
|
70070
70234
|
// of it (List's own `error`) would be a lie.
|
|
@@ -78720,7 +78884,7 @@ const TimeRangeWheel = ({
|
|
|
78720
78884
|
// the moment this must run is precisely the moment the pair is INVALID —
|
|
78721
78885
|
// an action-gated push would be refused by the very thing it fixes.
|
|
78722
78886
|
const keepBoundsApart = (movedSide, movedTime, e) => {
|
|
78723
|
-
const movedMinutes = minutesFromTime(movedTime);
|
|
78887
|
+
const movedMinutes = minutesFromTime$1(movedTime);
|
|
78724
78888
|
if (movedMinutes === null) {
|
|
78725
78889
|
return;
|
|
78726
78890
|
}
|
|
@@ -78728,7 +78892,7 @@ const TimeRangeWheel = ({
|
|
|
78728
78892
|
if (!otherEl) {
|
|
78729
78893
|
return;
|
|
78730
78894
|
}
|
|
78731
|
-
const otherMinutes = minutesFromTime(getUIStateFromElement(otherEl));
|
|
78895
|
+
const otherMinutes = minutesFromTime$1(getUIStateFromElement(otherEl));
|
|
78732
78896
|
if (otherMinutes === null) {
|
|
78733
78897
|
return;
|
|
78734
78898
|
}
|