@jsenv/navi 0.27.42 → 0.27.44

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.
@@ -3,7 +3,7 @@ import { isValidElement, createContext, h, toChildArray, render, Fragment, clone
3
3
  import { useErrorBoundary, useLayoutEffect, useEffect, useContext, useMemo, useRef, useState, useCallback, useId } from "preact/hooks";
4
4
  import { jsxs, jsx, Fragment as Fragment$1 } from "preact/jsx-runtime";
5
5
  import { signal, effect, computed, batch, useSignal } from "@preact/signals";
6
- import { createIterableWeakSet, createEventGroupLogger, normalizeStyle, mergeOneStyle, createPubSub, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, dispatchCustomEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, createStyleController, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, measureLongestVisualLineWidth, chainEvent, getKeyboardEventDefaultAction, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, activeElementSignal, initFocusGroup, elementIsFocusable, snapToPixel, trapScrollInside, trapFocusInside, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
6
+ import { createIterableWeakSet, createEventGroupLogger, normalizeStyle, mergeOneStyle, createPubSub, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, dispatchCustomEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, createStyleController, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, measureLongestVisualLineWidth, getKeyboardEventDefaultAction, chainEvent, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, activeElementSignal, initFocusGroup, elementIsFocusable, snapToPixel, trapScrollInside, trapFocusInside, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
7
  export { contrastColor, startDragToReorder } from "@jsenv/dom";
8
8
  import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize";
9
9
  import { createValidity, parseDuration, durationContainsNaN, compareTwoDurations, durationToSeconds, durationToISOString } from "@jsenv/validity";
@@ -18260,6 +18260,50 @@ naviI18n.addAll({
18260
18260
  },
18261
18261
  });
18262
18262
 
18263
+ // charGuard / maxLengthGuard callout messages
18264
+ naviI18n.addAll({
18265
+ // Preset-specific char messages — more informative than the generic fallback
18266
+ "constraint.guard.number": {
18267
+ fr: "Ce champ ne peut contenir que des chiffres.",
18268
+ en: "This field can only contain digits.",
18269
+ },
18270
+ "constraint.guard.alpha": {
18271
+ fr: "Ce champ ne peut contenir que des lettres.",
18272
+ en: "This field can only contain letters.",
18273
+ },
18274
+ "constraint.guard.alphanumeric": {
18275
+ fr: "Ce champ ne peut contenir que des lettres et des chiffres.",
18276
+ en: "This field can only contain letters and digits.",
18277
+ },
18278
+ "constraint.guard.uppercase": {
18279
+ fr: "Ce champ ne peut contenir que des lettres majuscules.",
18280
+ en: "This field can only contain uppercase letters.",
18281
+ },
18282
+ "constraint.guard.hex": {
18283
+ fr: "Ce champ ne peut contenir que des chiffres hexadécimaux (0-9, A-F).",
18284
+ en: "This field can only contain hexadecimal digits (0-9, A-F).",
18285
+ },
18286
+ "constraint.guard.slug": {
18287
+ fr: "Ce champ ne peut contenir que des lettres minuscules, des chiffres et des tirets.",
18288
+ en: "This field can only contain lowercase letters, digits, and hyphens.",
18289
+ },
18290
+ // Generic fallback for custom char classes and other presets (tel, card, postal, iban…)
18291
+ "constraint.guard.chars": {
18292
+ fr: "Ce champ ne peut contenir que les caractères autorisés.",
18293
+ en: "This field can only contain allowed characters.",
18294
+ },
18295
+ // maxLength: keydown blocked (one character would exceed the limit)
18296
+ "constraint.guard.max_length.typing": {
18297
+ fr: "Longueur maximale de [max] caractère[s] atteinte.",
18298
+ en: "Maximum length of [max] character[s] reached.",
18299
+ },
18300
+ // maxLength: paste/set truncated to maxLength (autofix always applied)
18301
+ "constraint.guard.max_length.value": {
18302
+ fr: "Ce champ ne peut pas contenir plus de [max] caractère[s], une partie a été tronquée.",
18303
+ en: "This field cannot contain more than [max] character[s]; the value was truncated.",
18304
+ },
18305
+ });
18306
+
18263
18307
  // Date/time placeholder tokens — shown when no value is selected
18264
18308
  // Override any key to adapt to your language conventions
18265
18309
  naviI18n.addAll({
@@ -19543,7 +19587,7 @@ const MAX_LENGTH_CONSTRAINT = {
19543
19587
  } else if (!isTextarea) {
19544
19588
  return null;
19545
19589
  }
19546
- const maxLength = field.controlHostProps.maxLength;
19590
+ const maxLength = field.controlHostProps.maxLength ?? field.props?.maxLengthGuard;
19547
19591
  if (maxLength === undefined) {
19548
19592
  return null;
19549
19593
  }
@@ -21888,6 +21932,9 @@ const CONTROL_PROP_SET = new Set([
21888
21932
  "resetOnCancel",
21889
21933
  "resetOnAbort",
21890
21934
  "resetOnError",
21935
+
21936
+ "charGuard",
21937
+ "maxLengthGuard",
21891
21938
  ]);
21892
21939
 
21893
21940
  const MessagePropsRefContext = createContext();
@@ -21905,6 +21952,221 @@ const ActionRequesterContext = createContext();
21905
21952
 
21906
21953
  const FormContext = createContext();
21907
21954
 
21955
+ /**
21956
+ * Named presets for the `charGuard` prop.
21957
+ * Each value is a regex character class (including the [ ] delimiters).
21958
+ */
21959
+ const CHAR_CLASS_PRESETS = {
21960
+ numeric: "[0-9]", // digits only
21961
+ alpha: "[A-Za-z]", // letters only
21962
+ alphanumeric: "[0-9A-Za-z]", // letters and digits
21963
+ uppercase: "[A-Z]", // uppercase letters only
21964
+ tel: "[-0-9+() ]", // phone: digits, +, -, parens, space
21965
+ card: "[0-9 ]", // credit card: digits and spaces
21966
+ hex: "[0-9A-Fa-f]", // hexadecimal digits
21967
+ pin: "[0-9]", // numeric PIN
21968
+ postal: "[0-9A-Za-z -]", // postal code (FR, UK, US)
21969
+ iban: "[0-9A-Z]", // IBAN: uppercase and digits
21970
+ slug: "[a-z0-9-]", // URL slug
21971
+ };
21972
+
21973
+ // Presets that imply a specific mobile keyboard layout
21974
+ const INPUT_MODE_FROM_PRESET = {
21975
+ numeric: "numeric",
21976
+ pin: "numeric",
21977
+ card: "numeric",
21978
+ tel: "tel",
21979
+ };
21980
+
21981
+ // Specific i18n keys per preset — more informative than the generic fallback
21982
+ const MESSAGE_KEY_FROM_PRESET = {
21983
+ numeric: "constraint.guard.number",
21984
+ pin: "constraint.guard.number",
21985
+ alpha: "constraint.guard.alpha",
21986
+ alphanumeric: "constraint.guard.alphanumeric",
21987
+ uppercase: "constraint.guard.uppercase",
21988
+ hex: "constraint.guard.hex",
21989
+ slug: "constraint.guard.slug",
21990
+ // tel, card, postal, iban, custom → generic fallback
21991
+ };
21992
+
21993
+ /**
21994
+ * Returns the regex character class for a preset name, or the raw value as-is
21995
+ * if it's not a known preset (allows custom classes like "[A-Z0-9_]").
21996
+ */
21997
+ const resolveCharClass = (value) => {
21998
+ if (!value) return null;
21999
+ return CHAR_CLASS_PRESETS[value] ?? value;
22000
+ };
22001
+
22002
+ /**
22003
+ * Returns the inputMode to auto-apply for the given preset, or null.
22004
+ */
22005
+ const resolveInputModeFromAllowedChars = (value) => {
22006
+ return INPUT_MODE_FROM_PRESET[value] ?? null;
22007
+ };
22008
+
22009
+ /**
22010
+ * Returns the i18n key for the char guard rejection message.
22011
+ * Falls back to the generic "constraint.guard.chars" for custom classes and
22012
+ * presets without a specific message.
22013
+ */
22014
+ const getCharGuardMessageKey = (value) => {
22015
+ return MESSAGE_KEY_FROM_PRESET[value] ?? "constraint.guard.chars";
22016
+ };
22017
+
22018
+ /**
22019
+ * Input guard — enforces character and length constraints during typing, paste,
22020
+ * and external value sets.
22021
+ *
22022
+ * The guard owns a single callout token (shared across all rejection reasons) so
22023
+ * successive rejections update the same callout rather than stacking.
22024
+ *
22025
+ * Exposed as `controller.rules.guard` (created inside `createControlRules`).
22026
+ *
22027
+ * Props read from `controller.props` on each call (always up to date):
22028
+ * - `charGuard` — preset name (e.g. "numeric", "tel") or raw char class "[A-Z]"
22029
+ * - `maxLengthGuard` — maximum character count (enables both guard and constraint)
22030
+ */
22031
+
22032
+
22033
+ const isTypingIntent = (e) =>
22034
+ getKeyboardEventDefaultAction(e) === "type";
22035
+
22036
+ const s = (n) => (n > 1 ? "s" : "");
22037
+
22038
+ // Keydown: block only single printable characters that don't match the class.
22039
+ // Multi-character key names (Delete, ArrowLeft…) are always allowed.
22040
+ const getInvalidCharMessage = (char, { charClass, messageKey }) => {
22041
+ if (char.length !== 1) return null;
22042
+ if (new RegExp(charClass).test(char)) return null;
22043
+ return naviI18n(messageKey);
22044
+ };
22045
+
22046
+ // Keydown: block when inserting one char would exceed maxLength.
22047
+ const getMaxLengthInsertionMessage = (el, { maxLength }) => {
22048
+ if (maxLength === undefined) return null;
22049
+ const selStart = el.selectionStart ?? el.value.length;
22050
+ const selEnd = el.selectionEnd ?? el.value.length;
22051
+ const newLen = el.value.length - (selEnd - selStart) + 1;
22052
+ if (newLen <= maxLength) return null;
22053
+ return naviI18n("constraint.guard.max_length.typing", {
22054
+ max: maxLength,
22055
+ s: s(maxLength),
22056
+ });
22057
+ };
22058
+
22059
+ // Paste / set: block when value contains disallowed chars.
22060
+ const getInvalidCharsMessage = (uiState, { charClass, messageKey }) => {
22061
+ const str = uiState === undefined ? "" : String(uiState);
22062
+ if (new RegExp(`^(?:${charClass})*$`).test(str)) return null;
22063
+ return naviI18n(messageKey);
22064
+ };
22065
+
22066
+ // Paste / set: truncate when value exceeds maxLength.
22067
+ const getLengthOverflowResult = (uiState, { maxLength }) => {
22068
+ if (maxLength === undefined) return null;
22069
+ const str = uiState === undefined ? "" : String(uiState);
22070
+ if (str.length <= maxLength) return null;
22071
+ return {
22072
+ fixedValue: str.slice(0, maxLength),
22073
+ message: naviI18n("constraint.guard.max_length.value", {
22074
+ max: maxLength,
22075
+ s: s(maxLength),
22076
+ }),
22077
+ };
22078
+ };
22079
+
22080
+ const createControlGuard = (controller) => {
22081
+ const token = createOpenToken();
22082
+
22083
+ const show = (message, e) => {
22084
+ controller.rules.callout.addOpenToken(token, {
22085
+ message,
22086
+ status: "info",
22087
+ event: e,
22088
+ skipFocus: true,
22089
+ });
22090
+ };
22091
+
22092
+ const clear = (e) => {
22093
+ controller.rules.callout.removeOpenToken(token, e);
22094
+ };
22095
+
22096
+ /**
22097
+ * Called on every keydown. Returns true when the key should be blocked
22098
+ * (caller must call e.preventDefault()).
22099
+ * Non-typing keys (Delete, Arrow…) are always allowed.
22100
+ */
22101
+ const checkKeydown = (e, el) => {
22102
+ if (!isTypingIntent(e)) {
22103
+ return false;
22104
+ }
22105
+ const { charGuard, maxLengthGuard } = controller.props;
22106
+
22107
+ if (charGuard) {
22108
+ const charClass = resolveCharClass(charGuard);
22109
+ const messageKey = getCharGuardMessageKey(charGuard);
22110
+ const charMsg = getInvalidCharMessage(e.key, { charClass, messageKey });
22111
+ if (charMsg) {
22112
+ show(charMsg, e);
22113
+ return true;
22114
+ }
22115
+ }
22116
+ if (maxLengthGuard !== undefined) {
22117
+ const lenMsg = getMaxLengthInsertionMessage(el, {
22118
+ maxLength: maxLengthGuard,
22119
+ });
22120
+ if (lenMsg) {
22121
+ show(lenMsg, e);
22122
+ return true;
22123
+ }
22124
+ }
22125
+ clear(e);
22126
+ return false;
22127
+ };
22128
+
22129
+ /**
22130
+ * Called when a full value is about to be applied (paste or external set).
22131
+ *
22132
+ * Returns:
22133
+ * null — value is valid, proceed normally
22134
+ * { blocked } — value rejected, caller must not apply it (callout shown)
22135
+ * { fixedValue } — value was truncated to maxLengthGuard (callout shown as info)
22136
+ */
22137
+ const checkUIState = (uiState, e) => {
22138
+ const { charGuard, maxLengthGuard } = controller.props;
22139
+
22140
+ if (charGuard) {
22141
+ const charClass = resolveCharClass(charGuard);
22142
+ const messageKey = getCharGuardMessageKey(charGuard);
22143
+ const charsMsg = getInvalidCharsMessage(uiState, {
22144
+ charClass,
22145
+ messageKey,
22146
+ });
22147
+ if (charsMsg) {
22148
+ show(charsMsg, e);
22149
+ return { blocked: true };
22150
+ }
22151
+ }
22152
+
22153
+ if (maxLengthGuard !== undefined) {
22154
+ const lengthResult = getLengthOverflowResult(uiState, {
22155
+ maxLength: maxLengthGuard,
22156
+ });
22157
+ if (lengthResult) {
22158
+ show(lengthResult.message, e);
22159
+ return { fixedValue: lengthResult.fixedValue };
22160
+ }
22161
+ }
22162
+
22163
+ clear(e);
22164
+ return null;
22165
+ };
22166
+
22167
+ return { checkKeydown, checkUIState, show, clear };
22168
+ };
22169
+
21908
22170
  /**
21909
22171
  * Orchestrates the three rule managers for a UI state controller.
21910
22172
  *
@@ -21921,13 +22183,13 @@ const FormContext = createContext();
21921
22183
 
21922
22184
 
21923
22185
  /**
21924
- * Creates all three rule managers for a controller and wires them together.
22186
+ * Creates all rule managers for a controller and wires them together.
21925
22187
  *
21926
22188
  * @param {object} controller - The UI state controller.
21927
22189
  * @param {object} [options]
21928
22190
  * @param {Function} [options.debugUIState] - Debug logger for state/validity events.
21929
22191
  * @param {Function} [options.debugFocus] - Debug logger for focus events.
21930
- * @returns {{ callout, interaction, validation, uninstall }}
22192
+ * @returns {{ callout, interaction, validation, guard, uninstall }}
21931
22193
  */
21932
22194
  const createControlRules = (
21933
22195
  controller,
@@ -21951,10 +22213,13 @@ const createControlRules = (
21951
22213
  debugUIState,
21952
22214
  });
21953
22215
 
22216
+ const guard = createControlGuard(controller);
22217
+
21954
22218
  return {
21955
22219
  callout,
21956
22220
  interaction,
21957
22221
  validation,
22222
+ guard,
21958
22223
  uninstall: teardown,
21959
22224
  };
21960
22225
  };
@@ -22183,6 +22448,18 @@ const useUIStateController = (
22183
22448
  }
22184
22449
  },
22185
22450
  setUIState: (newUIState, e) => {
22451
+ const guardResult = uiStateController.rules.guard.checkUIState(
22452
+ newUIState,
22453
+ e,
22454
+ );
22455
+ if (guardResult) {
22456
+ if (Object.hasOwn(guardResult, "fixedValue")) {
22457
+ newUIState = guardResult.fixedValue;
22458
+ // fall through — continue with truncated value (callout already shown by guard)
22459
+ } else {
22460
+ return false;
22461
+ }
22462
+ }
22186
22463
  const controllerSig = getElementSignature(e.currentTarget || ref.current);
22187
22464
  // if (persists) {
22188
22465
  // setNavState(prop);
@@ -23455,7 +23732,7 @@ const useControlProps = (props, {
23455
23732
  }
23456
23733
  };
23457
23734
  }
23458
- return null;
23735
+ return keyDownDefault(e);
23459
23736
  },
23460
23737
  click: e => {
23461
23738
  return {
@@ -23501,6 +23778,20 @@ const useControlProps = (props, {
23501
23778
  }
23502
23779
  if (controlType === "picker") {
23503
23780
  return {
23781
+ keyDown: e => {
23782
+ if (e.key === " ") {
23783
+ return {
23784
+ name: "space to click",
23785
+ allowed: () => {
23786
+ ref.current.click();
23787
+ },
23788
+ always: () => {
23789
+ e.preventDefault(); // prevent page scroll
23790
+ }
23791
+ };
23792
+ }
23793
+ return keyDownDefault(e);
23794
+ },
23504
23795
  input: e => {
23505
23796
  return {
23506
23797
  name: "input",
@@ -23676,7 +23967,14 @@ const useControlProps = (props, {
23676
23967
  const isInputTextual = controlType === "input";
23677
23968
  if (isInputTextual) {
23678
23969
  return {
23679
- keyDown: keyDownDefaultOnInput,
23970
+ keyDown: e => {
23971
+ const blocked = uiStateController.rules.guard.checkKeydown(e, ref.current);
23972
+ if (blocked) {
23973
+ e.preventDefault();
23974
+ return null;
23975
+ }
23976
+ return keyDownDefaultOnInput(e);
23977
+ },
23680
23978
  input: e => {
23681
23979
  return {
23682
23980
  name: "input",
@@ -23800,7 +24098,27 @@ const useControlProps = (props, {
23800
24098
  dispatchRequestInteraction(ref.current, {
23801
24099
  event: e,
23802
24100
  name: "paste",
23803
- prevented: () => e.preventDefault()
24101
+ prevented: () => e.preventDefault(),
24102
+ allowed: () => {
24103
+ const pastedText = e.clipboardData?.getData("text") ?? "";
24104
+ const el = ref.current;
24105
+ const selStart = el.selectionStart ?? el.value.length;
24106
+ const selEnd = el.selectionEnd ?? el.value.length;
24107
+ const newValue = el.value.slice(0, selStart) + pastedText + el.value.slice(selEnd);
24108
+ const guardResult = uiStateController.rules.guard.checkUIState(newValue, e);
24109
+ if (guardResult?.blocked) {
24110
+ e.preventDefault();
24111
+ return;
24112
+ }
24113
+ if (guardResult?.fixedValue !== undefined) {
24114
+ // Pass newValue (not fixedValue) so setUIState's guard shows the
24115
+ // truncation callout and applies the truncated value itself.
24116
+ e.preventDefault();
24117
+ uiStateController.setUIState(newValue, e);
24118
+ return;
24119
+ }
24120
+ // valid — let the browser paste; the input event will sync UI state
24121
+ }
23804
24122
  });
23805
24123
  };
23806
24124
  Object.assign(controlHostProps, {
@@ -23829,8 +24147,9 @@ const createControlInfo = (props, {
23829
24147
  let disabledSupported = false;
23830
24148
  let hasStateProp;
23831
24149
  let value;
24150
+ const typeProp = props.type || "text";
23832
24151
  if (controlType === "input") {
23833
- if (props.type === "checkbox" || props.type === "radio") {
24152
+ if (typeProp === "checkbox" || typeProp === "radio") {
23834
24153
  statePropName = "checked";
23835
24154
  defaultStatePropName = "defaultChecked";
23836
24155
  hasStateProp = Object.hasOwn(props, "checked");
@@ -23865,7 +24184,7 @@ const createControlInfo = (props, {
23865
24184
  } else {
23866
24185
  stateInitial = undefined;
23867
24186
  }
23868
- readOnlySupported = true;
24187
+ readOnlySupported = INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
23869
24188
  }
23870
24189
  disabledSupported = true;
23871
24190
  } else if (controlType === "button") {
@@ -23893,7 +24212,7 @@ const createControlInfo = (props, {
23893
24212
  stateInitial = undefined;
23894
24213
  }
23895
24214
  disabledSupported = true;
23896
- readOnlySupported = true; // it's an input under the hood
24215
+ readOnlySupported = INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
23897
24216
  }
23898
24217
  return {
23899
24218
  controlType,
@@ -23907,6 +24226,8 @@ const createControlInfo = (props, {
23907
24226
  disabledSupported
23908
24227
  };
23909
24228
  };
24229
+ // color, radio, image, file etc do not support readonly
24230
+ const INPUT_TYPE_SUPPORTING_READONLY_SET = new Set(["text", "date", "datetime-local", "email", "month", "number", "password", "search", "tel", "time", "url", "week"]);
23910
24231
  const useReadOnlyUncontrolled = (props, controlInfo) => {
23911
24232
  if (!controlInfo.hasStateProp) {
23912
24233
  return false;
@@ -32776,6 +33097,29 @@ installImportMetaCssBuild(import.meta);/**
32776
33097
  * For non-textual inputs, specialized components will be used:
32777
33098
  * - <InputCheckbox /> for type="checkbox"
32778
33099
  * - <InputRadio /> for type="radio"
33100
+ *
33101
+ * Guard props (immediate feedback instead of wait-for-submit):
33102
+ *
33103
+ * - charGuard — restricts which characters can be typed, pasted, or set externally.
33104
+ * Accepts a preset name or a raw regex character class:
33105
+ * "numeric" → digits only, sets inputMode="numeric" + pattern auto
33106
+ * "alpha" → letters only
33107
+ * "alphanumeric" → letters and digits
33108
+ * "uppercase" → uppercase letters only
33109
+ * "tel" → phone chars (digits, +, -, parens, space), sets inputMode="tel"
33110
+ * "card" → credit card (digits and spaces), sets inputMode="numeric"
33111
+ * "hex" → hexadecimal digits
33112
+ * "pin" → numeric PIN, sets inputMode="numeric"
33113
+ * "postal" → postal code (digits, letters, space, hyphen)
33114
+ * "iban" → IBAN (uppercase and digits)
33115
+ * "slug" → URL slug (lowercase, digits, hyphens)
33116
+ * "[A-Z0-9]" → any custom regex character class
33117
+ * inputMode and pattern are auto-derived from the preset when not explicitly set.
33118
+ *
33119
+ * - maxLengthGuard — combines maxLength + overflow guard in one prop.
33120
+ * Blocks keydown when the limit is reached; truncates on paste/set with an info callout.
33121
+ * The maxLength constraint remains active for form validation at submit.
33122
+ * Use plain maxLength (without maxLengthGuard) for submit-only validation.
32779
33123
  */
32780
33124
  const css$x = /* css */`
32781
33125
  @layer navi {
@@ -33151,6 +33495,18 @@ const InputTextualFirstResolver = props => {
33151
33495
  const defaultRef = useRef(null);
33152
33496
  props.ref = props.ref || defaultRef;
33153
33497
  resolveInputProps(props);
33498
+
33499
+ // charGuard → auto inputMode + auto pattern for mobile keyboard hints
33500
+ if (props.charGuard) {
33501
+ if (props.inputMode === undefined) {
33502
+ const autoMode = resolveInputModeFromAllowedChars(props.charGuard);
33503
+ if (autoMode) props.inputMode = autoMode;
33504
+ }
33505
+ if (props.pattern === undefined) {
33506
+ const charClass = resolveCharClass(props.charGuard);
33507
+ props.pattern = `${charClass}*`;
33508
+ }
33509
+ }
33154
33510
  return jsx(Next, {
33155
33511
  ...props
33156
33512
  });
@@ -33182,6 +33538,11 @@ const RealInput = props => {
33182
33538
  e.target.select();
33183
33539
  }
33184
33540
  }
33541
+ // Never set native maxLength — our guard handles it. maxLength stays in
33542
+ // inputControlHostProps so form validation constraints still read it.
33543
+ ,
33544
+
33545
+ maxLength: undefined
33185
33546
  });
33186
33547
  };
33187
33548
  const InputStyleCSSVars = {
@@ -35377,6 +35738,7 @@ installImportMetaCssBuild(import.meta);const css$p = /* css */`
35377
35738
  }
35378
35739
  `;
35379
35740
  const PickerCustomResolver = props => {
35741
+ import.meta.css = [css$p, "@jsenv/navi/src/control/picker/picker_custom.jsx"];
35380
35742
  if (props.children === undefined) {
35381
35743
  return jsx(PickerNative, {
35382
35744
  ...props
@@ -35408,6 +35770,9 @@ const PickerNative = props => {
35408
35770
  dispatchRequestInteraction(pickerInput, {
35409
35771
  event: e,
35410
35772
  name: "navi_request_open to show native picker",
35773
+ prevented: () => {
35774
+ e.preventDefault();
35775
+ },
35411
35776
  allowed: () => {
35412
35777
  try {
35413
35778
  pickerInput.showPicker();
@@ -35427,10 +35792,13 @@ const PickerNative = props => {
35427
35792
  allowed: () => {
35428
35793
  const pickerEl = props.ref.current;
35429
35794
  const pickerInput = getPickerInput(pickerEl);
35430
- // requestCloseValidityCallout(pickerEl, e);
35431
35795
  if (pickerInput.type === "color") ; else {
35432
35796
  // other picker might not open the picker when clicking the input surface (only the calendar picker for instance would open)
35433
- pickerInput.showPicker();
35797
+ try {
35798
+ pickerInput.showPicker();
35799
+ } catch {
35800
+ pickerInput.click();
35801
+ }
35434
35802
  }
35435
35803
  }
35436
35804
  };
@@ -35704,6 +36072,15 @@ const PickerCustom = props => {
35704
36072
  }
35705
36073
  };
35706
36074
  },
36075
+ "enter": e => {
36076
+ return {
36077
+ name: "enter_to_open",
36078
+ allowed: () => {
36079
+ requestOpen(e);
36080
+ e.preventDefault(); // prevent form submission
36081
+ }
36082
+ };
36083
+ },
35707
36084
  "escape": e => {
35708
36085
  if (!expandedRef.current) {
35709
36086
  return null;
@@ -35742,6 +36119,9 @@ const PickerCustom = props => {
35742
36119
  // in that case we want to open it (otherwise we have already opened on mousedown interaction)
35743
36120
  return {
35744
36121
  name: e.detail === 0 ? "click (keyboard or progammatic) to open picker" : "click to open picker",
36122
+ prevented: () => {
36123
+ e.preventDefault();
36124
+ },
35745
36125
  allowed: () => {
35746
36126
  requestOpen(e);
35747
36127
  e.preventDefault();
@@ -35810,7 +36190,6 @@ const getPickerInputUIState = pickerEl => {
35810
36190
  };
35811
36191
  const PickerContentInsidePopover = props => {
35812
36192
  const Next = useNextResolver();
35813
- import.meta.css = [css$p, "@jsenv/navi/src/control/picker/picker_custom.jsx"];
35814
36193
  const {
35815
36194
  popupProps,
35816
36195
  children,
@@ -35882,7 +36261,6 @@ const PickerContentInsidePopover = props => {
35882
36261
  };
35883
36262
  const PickerContentInsideDialog = props => {
35884
36263
  const Next = useNextResolver();
35885
- import.meta.css = [css$p, "@jsenv/navi/src/control/picker/picker_custom.jsx"];
35886
36264
  const {
35887
36265
  popupProps,
35888
36266
  children,
@@ -37037,6 +37415,9 @@ installImportMetaCssBuild(import.meta);const css$n = /* css */`
37037
37415
  }
37038
37416
 
37039
37417
  .navi_list_item[navi-selectable] {
37418
+ --list-item-padding-x-default: inherit;
37419
+ --list-item-padding-y-default: inherit;
37420
+
37040
37421
  outline-width: var(--list-item-outline-width);
37041
37422
  outline-color: var(--list-item-outline-color);
37042
37423
  outline-offset: var(--list-item-outline-offset);
@@ -37081,6 +37462,7 @@ installImportMetaCssBuild(import.meta);const css$n = /* css */`
37081
37462
  --list-item-background-color-keyboard-pointed
37082
37463
  );
37083
37464
  outline-style: solid;
37465
+ --x-list-item-border-color: transparent;
37084
37466
 
37085
37467
  /* Selected must win over keyboard-pointed */
37086
37468
  &[data-selected] {
@@ -37844,6 +38226,10 @@ const css$m = /* css */`
37844
38226
  );
37845
38227
  color: var(--x-list-item-color);
37846
38228
  font-weight: var(--x-list-item-font-weight);
38229
+ /* Border-radius can cause background to appear behind the borders or behind the outline */
38230
+ /* padding-box ensure background stays inside borders */
38231
+ /* For outline we also set border-color transparent when focused */
38232
+ background-clip: padding-box;
37847
38233
  background-color: var(--x-list-item-background-color);
37848
38234
  border: var(--x-list-item-border-width) solid
37849
38235
  var(--x-list-item-border-color);
@@ -39899,13 +40285,12 @@ installImportMetaCssBuild(import.meta);const css$i = /* css */`
39899
40285
  }
39900
40286
  }
39901
40287
  .navi_picker_right_slot {
39902
- --slot-spacing: calc(0.1em);
40288
+ --slot-spacing: calc(var(--x-picker-padding-right) * 0.5);
39903
40289
 
39904
40290
  display: inline-flex;
39905
40291
  height: 1em;
39906
40292
  height: 1lh;
39907
40293
  margin-right: var(--slot-spacing);
39908
- margin-left: var(--slot-spacing);
39909
40294
  flex-shrink: 0;
39910
40295
  align-items: center;
39911
40296
  align-self: flex-start;
@@ -39924,17 +40309,13 @@ installImportMetaCssBuild(import.meta);const css$i = /* css */`
39924
40309
  }
39925
40310
  }
39926
40311
  .navi_picker_input {
39927
- position: absolute;
39928
- inset: 0;
39929
40312
  box-sizing: border-box;
39930
- width: 100%;
39931
- height: 100%;
39932
40313
  margin: 0;
39933
40314
  padding: 0;
39934
40315
  background: none;
39935
40316
  border: none;
39936
- opacity: 0;
39937
- appearance: none;
40317
+ border-radius: inherit;
40318
+ outline: none;
39938
40319
  cursor: inherit;
39939
40320
  pointer-events: auto;
39940
40321
 
@@ -39943,6 +40324,22 @@ installImportMetaCssBuild(import.meta);const css$i = /* css */`
39943
40324
  }
39944
40325
  }
39945
40326
 
40327
+ &[navi-ui-custom] {
40328
+ .navi_picker_input {
40329
+ position: absolute;
40330
+ top: calc(-1 * var(--picker-border-width));
40331
+ right: calc(-1 * var(--picker-border-width));
40332
+ bottom: calc(-1 * var(--picker-border-width));
40333
+ left: calc(-1 * var(--picker-border-width));
40334
+ /* Reset width/height for input color */
40335
+ width: auto;
40336
+ height: auto;
40337
+
40338
+ opacity: 0;
40339
+ appearance: none;
40340
+ }
40341
+ }
40342
+
39946
40343
  .navi_picker_content {
39947
40344
  display: contents;
39948
40345
  text-align: initial; /* Don't inherit picker text align */
@@ -40073,6 +40470,7 @@ const PickerButton = props => {
40073
40470
  "data-variant": variant,
40074
40471
  "navi-picker": "",
40075
40472
  "navi-single-line": isSingleLine ? "" : undefined,
40473
+ "navi-ui-custom": ui === "default" ? undefined : "",
40076
40474
  ...pickerRemainingProps,
40077
40475
  basePseudoState: basePseudoState,
40078
40476
  styleCSSVars: PickerStyleCSSVars,
@@ -40104,6 +40502,7 @@ const PickerButton = props => {
40104
40502
  children: undefined // we will render children into the div
40105
40503
  ,
40106
40504
 
40505
+ ui: ui,
40107
40506
  onFocus: e => {
40108
40507
  inputProps.onFocus?.(e);
40109
40508
  e.target.select();
@@ -40173,7 +40572,7 @@ const PickerButton = props => {
40173
40572
  });
40174
40573
  e.preventDefault();
40175
40574
  }
40176
- }), variant === "icon" || variant === "headless" ? null : jsx(Text, {
40575
+ }), variant === "icon" || variant === "headless" || ui === "default" ? null : jsx(Text, {
40177
40576
  className: "navi_picker_value",
40178
40577
  "navi-placeholder": value === undefined || value === "" ? "" : undefined,
40179
40578
  maxLines: maxLines,
@@ -40185,7 +40584,7 @@ const PickerButton = props => {
40185
40584
  },
40186
40585
  children: ui === undefined ? jsx(PickerDefaultUI, {}) : ui
40187
40586
  })
40188
- }), variant === "headless" ? null : jsx("span", {
40587
+ }), variant === "headless" || ui === "default" ? null : jsx("span", {
40189
40588
  className: "navi_picker_right_slot",
40190
40589
  children: jsx(Icon, {
40191
40590
  size: "m",
@@ -40204,12 +40603,21 @@ const isWithinPickerContent = (el, pickerEl) => {
40204
40603
  return pickerEl.querySelector(".navi_picker_content")?.contains(el);
40205
40604
  };
40206
40605
  const PickerInput = props => {
40606
+ const {
40607
+ ui
40608
+ } = props;
40609
+
40610
+ // After type resolution: force readOnly when the input type would open the
40611
+ // mobile keyboard. We also suppress the visual ":read-only" state so the
40612
+ // picker still looks interactive (it is — just not keyboard-typeable).
40613
+ if (MOBILE_KEYBOARD_TYPES.has(props.type) && !props.readOnly) {
40614
+ props.readOnly = true;
40615
+ props["data-readonly-forced"] = "";
40616
+ }
40207
40617
  return jsx(Box, {
40208
40618
  as: "input",
40209
- "data-readonly-forced": props.readOnly ? undefined : "",
40210
40619
  ...props,
40211
- // must be readOnly to prevent opening keyboard on mobile (and direct update via keyboard too, we want people to use the picker)
40212
- readOnly: true,
40620
+ ui: undefined,
40213
40621
  className: "navi_picker_input",
40214
40622
  pseudoClasses: PickerInputPseudoClasses,
40215
40623
  onKeyDown: e => {
@@ -40217,12 +40625,17 @@ const PickerInput = props => {
40217
40625
  if (e.key === "Enter") {
40218
40626
  // prevent form submission now that input can have focus
40219
40627
  e.preventDefault();
40220
- } else if (e.key === "Tab") {
40628
+ } else if (e.key === "Tab" && ui !== "default") {
40629
+ // Ensure tab does not tab through the browser picker elements (like in input date)
40221
40630
  performTabNavigation(e);
40222
40631
  }
40223
40632
  }
40224
40633
  });
40225
40634
  };
40635
+ // Input types that open the software keyboard on mobile.
40636
+ // When the picker's underlying input has one of these types, we force readOnly
40637
+ // so tapping the picker doesn't open the keyboard (the picker manages its own UI).
40638
+ const MOBILE_KEYBOARD_TYPES = new Set(["text", "email", "url", "search", "password", "tel", "number"]);
40226
40639
  const PICKER_BUTTON_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-loading", ":-navi-expanded", ":-navi-has-value"];
40227
40640
  const PickerInputPseudoClasses = [":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading", ":-navi-has-value", ":-navi-expanded"];
40228
40641
  const PickerStyleCSSVars = {