@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.
@@ -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, CHAR_CLASS_PRESETS, parseDuration, durationToISOString } from "@jsenv/validity";
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
- * Converts a JS value into the form expected by the browser DOM property for a
4622
- * given control type/input type combination.
4623
- *
4624
- * For example:
4625
- * - `datetime-local` inputs expect a local datetime string without timezone
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 asControlHostValue = (
4638
- jsValue,
4639
- { controlType, type, inputMode, pad },
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
- return jsValue;
4664
- };
4665
- // As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
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 year = date.getFullYear();
4673
- const month = String(date.getMonth() + 1).padStart(2, "0");
4674
- const day = String(date.getDate()).padStart(2, "0");
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
- // `pad` is how many digits the number is WRITTEN on — an hour is held as 7 and
4681
- // shown as "07". Held and shown are two things here, the way they are for a
4682
- // datetime-local above: what the field says is derived from what the control
4683
- // holds, and reading it back (readNumberFromInput) gives the number again.
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
- if (!pad || jsValue === "" || jsValue === null) {
4689
- return jsValue;
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
- const number = Number(jsValue);
4692
- if (Number.isNaN(number)) {
4693
- return jsValue;
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
- const negative = number < 0;
4696
- const digits = String(negative ? -number : number).padStart(Number(pad), "0");
4697
- return negative ? `-${digits}` : digits;
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 jsValue;
4619
+ return false;
4710
4620
  };
4711
4621
 
4712
4622
  /**
4713
- * Reads the current logical JS value from a control host DOM element.
4623
+ * Returns the current time as "HH:MM", with an optional minute offset.
4714
4624
  *
4715
- * Handles all navi control host element types:
4716
- * - `<button>` reads via `navi_get_value` custom event, falls back to `button.value`
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
- * @param {HTMLElement} controlHost - The control host DOM element to read from.
4725
- * @returns {any} The current logical value of the control.
4628
+ * @example
4629
+ * getNowHours() // "14:30"
4630
+ * getNowHours(-5) // "14:25"
4726
4631
  */
4727
- const readControlValue = (controlHost) => {
4728
- if (
4729
- controlHost.tagName === "BUTTON" ||
4730
- controlHost.getAttribute("role") === "button"
4731
- ) {
4732
- return readValueFromButton(controlHost);
4733
- }
4734
- if (controlHost.tagName === "INPUT") {
4735
- // important: input.type = "navi_js"; followed by input.type; returns "text"
4736
- // so use getAttribute
4737
- const type = controlHost.getAttribute("type");
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
- const readDatetimeLocalFromInput = (input) => {
4774
- const localDateTimeString = input.value;
4775
- if (localDateTimeString === "") {
4776
- return "";
4777
- }
4778
- const localDate = new Date(localDateTimeString);
4779
- if (isNaN(localDate.getTime())) {
4780
- return localDateTimeString;
4781
- }
4782
- return localDate.toISOString();
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
- const readNumberFromInput = (input) => {
4785
- const numberString = input.value;
4786
- if (numberString === "") {
4787
- return "";
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 asNumber = Number(numberString);
4790
- if (isNaN(asNumber)) {
4791
- return numberString;
4682
+ const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
4683
+ if (!match) {
4684
+ return null;
4792
4685
  }
4793
- return asNumber;
4686
+ return { hour: Number(match[1]), minute: Number(match[2]) };
4794
4687
  };
4795
- const readValueFromCheckableInput = (input) => {
4796
- const checked = input.checked;
4797
- if (!checked) {
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 readValueFromControlHost(input);
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
- const readValueFromNaviCustomEvent = (field, fallback) => {
4811
- // prefer the value given as prop (respect original type, browser would convert to string)
4812
- let responded;
4813
- let value;
4814
- dispatchCustomEvent(field, "navi_get_value", {
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 fallback;
4708
+ return parts.hour * 60 + parts.minute;
4824
4709
  };
4825
4710
 
4826
- // In-memory registry of all mounted ui state controllers keyed by their id.
4827
- // Allows direct controller access without dispatching DOM events — used by external
4828
- // callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
4829
- const controllersById = new Map();
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
- // In-memory registry for radio controllers, keyed by input name.
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
- // Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
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
- const getUIStateControllerById = (id) => controllersById.get(id);
4844
- const getRadioSiblings = (radioUIStateController) => {
4845
- const siblings = radioControllersByName.get(radioUIStateController.name);
4846
- return siblings;
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
- const toDomValue = (
4850
- jsValue,
4851
- { controlType, id, type, inputMode, pad },
4852
- ) => {
4853
- const domValue = asControlHostValue(jsValue, {
4854
- controlType,
4855
- type,
4856
- inputMode,
4857
- pad,
4858
- });
4859
- if (isSerializableAsDomValue(domValue)) {
4860
- return domValue;
4861
- }
4862
- naviJsRegistry.set(id, domValue);
4863
- return `window.__navi_js('${id}')`;
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
- window.__navi_js = (id) => naviJsRegistry.get(id);
4867
- const isSerializableAsDomValue = (value) => {
4868
- if (value === null || value === undefined) {
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
- const type = typeof value;
4872
- return type === "string" || type === "number" || type === "boolean";
4781
+ return NUMBER_NAVI_INPUT_TYPE_SET.has(naviInputType);
4873
4782
  };
4874
4783
 
4875
- const onUIStateControllerCreated = (uiStateController) => {
4876
- const { id, name, controlType } = uiStateController;
4877
- if (id) {
4878
- controllersById.set(id, uiStateController);
4879
- }
4880
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
4881
- if (proxyFor) {
4882
- let proxySet = proxyControllersByRealInputId.get(proxyFor);
4883
- if (!proxySet) {
4884
- proxySet = new Set();
4885
- proxyControllersByRealInputId.set(proxyFor, proxySet);
4886
- }
4887
- proxySet.add(uiStateController);
4888
- }
4889
- if (
4890
- controlType === "input" &&
4891
- uiStateController.props.type === "radio" &&
4892
- name
4893
- ) {
4894
- let set = radioControllersByName.get(name);
4895
- if (!set) {
4896
- set = new Set();
4897
- radioControllersByName.set(name, set);
4898
- }
4899
- set.add(uiStateController);
4900
- }
4901
- };
4902
- const onUIStateControllerDestroyed = (uiStateController) => {
4903
- const { id, name, controlType } = uiStateController;
4904
- if (id) {
4905
- // Only the controller the id currently points at may take the entry away:
4906
- // when two controls share an id, the one leaving would otherwise unregister
4907
- // the one staying (see the warning in onUIStateControllerCreated), and the
4908
- // same holds while a control is being replaced by its successor, which
4909
- // registers during its render, before this cleanup runs.
4910
- if (controllersById.get(id) === uiStateController) {
4911
- controllersById.delete(id);
4912
- naviJsRegistry.delete(id);
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
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
4916
- if (proxyFor) {
4917
- const proxySet = proxyControllersByRealInputId.get(proxyFor);
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
- if (
4926
- controlType === "input" &&
4927
- uiStateController.controlHostProps.type === "radio" &&
4928
- name
4929
- ) {
4930
- const set = radioControllersByName.get(name);
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
- * Controller-based equivalent of findControlProxyTarget.
4942
- * Given a proxy controller, returns the real control's controller.
4943
- * Finds the target by walking the parent controller's children — no DOM queries.
4944
- * Returns `null` when the controller is not a proxy or the target is not found.
4945
- */
4946
- const findControlProxyTargetController = (controller) => {
4947
- const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
4948
- if (!proxyFor) {
4949
- return null;
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
- // Reverse-lookup map: real-input id → the proxy controllers that reference it
4955
- // via `navi-control-proxy-for`. A single control can be represented by several
4956
- // proxies (an "enable"/"disable" button pair for one radio, for instance), so
4957
- // each id holds a set. Maintained on create/destroy so lookup is O(1).
4958
- const proxyControllersByRealInputId = new Map();
4959
- const findProxyControllers = (realInputId) => {
4960
- if (!realInputId) {
4961
- return null;
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
- * The attributes constraints read, filled by each constraint module as it
4968
- * evaluates. A constraint declares the attribute it wants (`"data-no-emoji"`)
4969
- * and gets the prop for free: a control accepts the camelCase form
4970
- * (`noEmoji`) and writes it on the control host under the attribute name — the
4971
- * same conversion `element.dataset` does, so what a component is passed and
4972
- * what ends up in the DOM read as one thing.
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 CONSTRAINT_ATTRIBUTE_SET = new Set();
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
- const dataAttributeCache = new Map();
4978
- // A constraint imported lazily registers its attribute after controls have
4979
- // already rendered, so an answer computed before it arrived must not survive it.
4980
- let attributeCountWhenCached = 0;
4981
- /**
4982
- * The constraint attribute a prop stands for, `null` when it stands for none:
4983
- * `"noEmoji"` `"data-no-emoji"`.
4984
- */
4985
- const constraintAttributeFromProp = (key) => {
4986
- if (attributeCountWhenCached !== CONSTRAINT_ATTRIBUTE_SET.size) {
4987
- dataAttributeCache.clear();
4988
- attributeCountWhenCached = CONSTRAINT_ATTRIBUTE_SET.size;
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
- const fromCache = dataAttributeCache.get(key);
4991
- if (fromCache !== undefined) {
4992
- return fromCache;
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
- let attribute = null;
4995
- // An attribute is already written as one (`data-no-emoji`, `aria-label`) —
4996
- // there is nothing to convert, and the literal lookup has already happened.
4997
- if (!key.includes("-")) {
4998
- const candidate = `data-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
4999
- if (CONSTRAINT_ATTRIBUTE_SET.has(candidate)) {
5000
- attribute = candidate;
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
- dataAttributeCache.set(key, attribute);
5004
- return attribute;
5021
+ const targetType = currentTypeDefaults.type;
5022
+ props.type = targetType;
5023
+ resolveInputProps(props);
5005
5024
  };
5006
5025
 
5007
- /**
5008
- * Whether a constraint attribute is on. Present means on — `""` is how HTML
5009
- * writes a bare attribute — and only the values that say "passed, and off"
5010
- * turn it off.
5011
- */
5012
- const isConstraintAttributeOn = (value) =>
5013
- value !== undefined && value !== null && value !== false;
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 CONSTRAINT_NAME_TO_PROP = {
5016
- disabled: "disabledMessage",
5017
- required: "requiredMessage",
5018
- pattern: "patternMessage",
5019
- type_email: "typeMessage",
5020
- type_number: "typeMessage",
5021
- min_length: "minLengthMessage",
5022
- max_length: "maxLengthMessage",
5023
- min: "minMessage",
5024
- max: "maxMessage",
5025
- single_space: "singleSpaceMessage",
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 CONSTRAINT_MESSAGE_PROP_NAME_SET = new Set(
5041
- Object.values(CONSTRAINT_NAME_TO_PROP),
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 extractMessageAndRemainingProps = (props) => {
5045
- const ownMessages = {};
5046
- const remaining = {};
5047
- const keyToVisit = new Set(Object.keys(props));
5048
- for (const key of keyToVisit) {
5049
- if (CONSTRAINT_MESSAGE_PROP_NAME_SET.has(key)) {
5050
- ownMessages[key] = props[key];
5051
- } else {
5052
- remaining[key] = props[key];
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
- return [ownMessages, remaining];
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
- * The one-line form of a constraint message, or `undefined` when it has none.
5060
- *
5061
- * A message is not always a sentence: it can be an `Error`, or a whole element
5062
- * (an `errorMapping` returning JSX to put a link inside the callout). Those
5063
- * still render fine in the callout, but they have no one-line form, and
5064
- * anything that needs one a `title` attribute, a caller drawing its own
5065
- * summary must be given nothing rather than `String(message)`, which writes
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 getMessageString = (message) => {
5069
- if (typeof message === "string") {
5070
- return message;
5071
- }
5072
- if (Error.isError(message)) {
5073
- return message.message;
5074
- }
5075
- return undefined;
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
- const getConstraintMessage = (
5079
- controller,
5080
- constraint,
5081
- generatedMessage,
5082
- { requester },
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
- const { name: constraintName } = constraint;
5085
- const propName = CONSTRAINT_NAME_TO_PROP[constraintName];
5086
-
5087
- // 1. Search first on the requester (e.g. the <li> that was clicked),
5088
- // then fall back to element (e.g. the hidden <input>).
5089
- if (requester) {
5090
- const requesterController = requester.__uiStateController__;
5091
- if (requesterController && requesterController !== controller) {
5092
- const requesterControllerMessage = requesterController.props[propName];
5093
- if (requesterControllerMessage) {
5094
- return {
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
- const controllerMessage = controller.props[propName];
5103
- if (controllerMessage) {
5104
- return {
5105
- message: controllerMessage,
5106
- origin: "controller",
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
- return {
5111
- message: generatedMessage,
5112
- origin: "generated message",
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
- // prop that we'll set on the control.
5117
- // CONSTRAINT_ATTRIBUTE_SET is consulted through controlAttributeFromProp()
5118
- // rather than spread in here: a constraint registers into it when its own
5119
- // module evaluates, so anything read at module-eval time reads a set that is
5120
- // still filling up — and in a bundle, whichever constraint happens to evaluate
5121
- // last would silently lose its attribute.
5122
- const CONTROL_ATTRIBUTE_SET = new Set([
5123
- "ref",
5124
- "children",
5125
- "id",
5126
- "name",
5127
- "type",
5128
- "value",
5129
- "checked",
5130
- "placeholder",
5131
- "inputMode",
5132
- "autoComplete",
5133
- "spellcheck",
5134
- "autoCorrect",
5135
- "aria-controls",
5136
- // The name goes where the role is: the root box has none, so an aria-label
5137
- // left on it names nothing at all, while the host is what the user focuses
5138
- // and what a screen reader announces (and what getByRole({ name }) reads).
5139
- "aria-label",
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
- const inputMode = field.controlHostProps.inputMode;
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 inputMode = field.controlHostProps.inputMode;
8614
- const isNumber =
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 inputMode = field.controlHostProps.inputMode;
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
- const isNumber =
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 inputMode = field.controlHostProps.inputMode;
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
- const isNumber =
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 inputMode = field.controlHostProps.inputMode;
8917
- const isNumericText =
8918
- type === "text" && (inputMode === "numeric" || inputMode === "decimal");
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$1(otherController.uiState);
12101
- const timeAfter = minutesFromTime$1(field.uiState);
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$1 = (time) => {
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
- // The mount-time open, from the first run below until the effect after it
34130
- // could schedule it.
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 mount-time open — on every render, until it can. It has
34196
- // to wait for the element to be IN THE DOCUMENT, and a mount does not
34197
- // guarantee that: a `<Loading>` above the popup parks a suspended subtree by
34198
- // moving its dom into a detached <div> while keeping its components alive
34199
- // (preact/compat), and a render there re-creates the hooks, so the first run
34200
- // above happens against dom that is not in the page — where showModal() and
34201
- // showPopover() throw. The boundary settling re-renders the subtree with its
34202
- // dom back, and that render is the one that schedules.
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
- getKeyboardEventDefaultAction(e) === "type";
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 only single printable characters that don't match the class.
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
- * Non-typing keys (Delete, Arrow…) are always allowed.
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(e.key, { charClass, messageKey });
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
- inputMode: props.inputMode,
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
- const targetType = currentTypeDefaults.type;
53012
- props.type = targetType;
53013
- resolveInputProps(props);
53014
- };
52813
+ e.preventDefault();
52814
+ e.target.select();
52815
+ };
53015
52816
 
53016
- // Presets that imply a specific mobile keyboard inputMode.
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
- const normalizeToDate = (value) => {
53026
- if (value === undefined || value === null) {
53027
- return null;
53028
- }
53029
- if (typeof value === "number") {
53030
- return new Date(value);
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 (value instanceof Date) {
53033
- return value;
52887
+ if (props.type === "hidden") {
52888
+ return jsx(InputHidden, {
52889
+ ...props
52890
+ });
53034
52891
  }
53035
- return null;
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
- const toInputDate = (value) => {
53039
- const date = normalizeToDate(value);
53040
- if (!date) {
53041
- return value;
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
- const yyyy = date.getFullYear();
53044
- const mm = String(date.getMonth() + 1).padStart(2, "0");
53045
- const dd = String(date.getDate()).padStart(2, "0");
53046
- return `${yyyy}-${mm}-${dd}`;
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
- const toInputMonth = (value) => {
53049
- const date = normalizeToDate(value);
53050
- if (!date) {
53051
- return value;
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
- const yyyy = date.getFullYear();
53054
- const mm = String(date.getMonth() + 1).padStart(2, "0");
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
- // ISO week number
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
- const toInputTime = (value) => {
53074
- const date = normalizeToDate(value);
53075
- if (!date) {
53076
- return value;
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
- const hh = String(date.getHours()).padStart(2, "0");
53079
- const mm = String(date.getMinutes()).padStart(2, "0");
53080
- return `${hh}:${mm}`;
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 toInputDatetime = (value) => {
53083
- const date = normalizeToDate(value);
53084
- if (!date) {
53085
- return value;
53080
+ const InputTextualAsTextResolver = props => {
53081
+ const Next = useNextResolver();
53082
+ if (props.variant === "text") {
53083
+ return jsx(InputTextualAsText, {
53084
+ ...props
53085
+ });
53086
53086
  }
53087
- const yyyy = date.getFullYear();
53088
- const mm = String(date.getMonth() + 1).padStart(2, "0");
53089
- const dd = String(date.getDate()).padStart(2, "0");
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 MIN_MAX_FORMATTER_BY_TYPE = {
53096
- "date": toInputDate,
53097
- "month": toInputMonth,
53098
- "week": toInputWeek,
53099
- "time": toInputTime,
53100
- "datetime-local": toInputDatetime,
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 STEP_FORMATTER_BY_TYPE = {
53104
- "time": timeStringToSeconds,
53105
- "datetime-local": timeStringToSeconds,
53106
- "datetime": timeStringToSeconds,
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
- const hasDecimalPlaces = (value) => {
53110
- if (value === undefined || value === null) {
53111
- return false;
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, and where it stands among the rows the list holds. Carried
65999
- // by context rather than injected into whatever vnode renderItem returned, so
66000
- // that returning a component of one's own instead of a bare <List.Item> —
66001
- // works the same way.
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, decided it is inside the render window, and placed its
68838
- // separator. All that is left here is to draw it.
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 (!row) {
68911
+ if (row) {
68871
68912
  if (props.filtered) {
68872
- virtual.drop(idDefault);
68913
+ row.run.unmount(idDefault);
68873
68914
  } else {
68874
- props.index = virtual.take(idDefault, 1, slotId);
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
- virtual.drop(idDefault);
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
- // "Am I the first visible item?" is answered by the place the list handed
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
- // separatorIndex is only used as the function-form argument (gap index)
68932
- const separatorIndex = groupVisibleIndex === null ? index : groupVisibleIndex;
68933
- const separatorVnode = resolveSeparatorVnode(separator, separatorIndex - 1);
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 || rowVnodesRef.current.separator !== separator) {
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. Asked
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 opensGroup = groupKey => groupKey !== undefined && (!group || group.key !== groupKey);
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
- let rowKept = null;
69986
- if (item !== undefined) {
69987
- const rowVnodeKept = rowVnodesByItem.get(item);
69988
- if (rowVnodeKept && rowVnodeKept.rowIndex === rowIndex && rowVnodeKept.refreshing === renderItemState.refreshing) {
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 = jsx(ListItem, {
70023
- skeleton: true
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: item === undefined ? {
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
  }