@jsenv/navi 0.29.406 → 0.29.408

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.
@@ -2,9 +2,8 @@
2
2
  * AI reading this file: read ../docs/AI_INSTRUCTIONS.md for context on
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
- import { installImportMetaCssBuild, isSignal, getSignalType, findControlProxy, findControlRoot, isControl, findControlHost, isNumberInput, findControlProxyTargetController, findNearestControlHost, dispatchRequestSetUIState, getUIStateFromElement, getUIStateControllerById, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, PSEUDO_STATE_DEFAULT, PSEUDO_NAMED_STYLES_DEFAULT, initPseudoStyles, applyStyle, getParentControl, isControlRoot, readControlValue, dispatchRequestClearUIState, dispatchRequestResetUIState, findClosestControlWithAction, isEditableTarget, coarsePointerSignal, isKeyboardModality, warnSignalAsState, warnSignalCollision, onUIStateControllerDestroyed, getRadioSiblings, findProxyControllers, addInputEffect, findControlProxyTarget, isMatchingFocusVisible, onUIStateControllerCreated, toDomValue, PSEUDO_CLASSES, subscribeToPseudoState, resolveInputProps, smallTouchScreenSignal, timeStringToSeconds, parseTimeParts, formatTimeParts, seedDefaultValueFromSignal, minutesFromTime as minutesFromTime$1, timeFromMinutes } from "./jsenv_navi_side_effects.js";
6
- export { disableVirtualKeyboardOverlay, getNowHours, getNowHoursRoundedToStep, isToday } from "./jsenv_navi_side_effects.js";
7
- import { createPubSub, elementIsFocusable, createIterableWeakSet, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, dispatchCustomEvent, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, dispatchInternalCustomEvent, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, createInternalCustomEvent, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, getScrollContainer, watchWheelTravel, scrollRoomTowards, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, trapScrollInside, parsePositionArea, snapToPixel, trapFocusInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
5
+ import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
+ import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, createInternalCustomEvent, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, getScrollContainer, watchWheelTravel, scrollRoomTowards, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, isScrollable, resolveOklchLightness, contrastColor, trapScrollInside, parsePositionArea, snapToPixel, trapFocusInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange } from "@jsenv/dom";
8
7
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
9
8
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
9
  import { options, isValidElement, createContext, render, h, toChildArray, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
@@ -12,7 +11,7 @@ import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, use
12
11
  import { humanizeI18n, prefixFirstAndIndentRemainingLines, 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
12
  export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
14
13
  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, parseDuration, durationToISOString } from "@jsenv/validity";
14
+ 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
15
  export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
17
16
  import { Suspense, createPortal, forwardRef } from "preact/compat";
18
17
 
@@ -1604,6 +1603,27 @@ naviI18n.addAll({
1604
1603
  },
1605
1604
  });
1606
1605
 
1606
+ const isSignal = (value) => {
1607
+ return getSignalType(value) !== null;
1608
+ };
1609
+
1610
+ const BRAND_SYMBOL = Symbol.for("preact-signals");
1611
+ const getSignalType = (value) => {
1612
+ if (!value || typeof value !== "object") {
1613
+ return null;
1614
+ }
1615
+
1616
+ if (value.brand !== BRAND_SYMBOL) {
1617
+ return null;
1618
+ }
1619
+
1620
+ if (typeof value._fn === "function") {
1621
+ return "computed";
1622
+ }
1623
+
1624
+ return "signal";
1625
+ };
1626
+
1607
1627
  /*
1608
1628
  * The network policy: one declaration saying whether a request may go out,
1609
1629
  * read by the action layer rather than by every callback.
@@ -4897,6 +4917,1178 @@ const actionRunEffect = (
4897
4917
  return actionRunnedByThisEffect;
4898
4918
  };
4899
4919
 
4920
+ /**
4921
+ * DOM utilities for navigating the control element hierarchy.
4922
+ *
4923
+ * A control is a self-contained interactive widget. Its DOM structure can be
4924
+ * either flat (host only) or layered (wrapper + host):
4925
+ *
4926
+ * Flat — the element is both the root and the host:
4927
+ * ```html
4928
+ * <button navi-control navi-control-host>Click me</button>
4929
+ * ```
4930
+ *
4931
+ * Layered — a visual wrapper surrounds a native input that is the real host:
4932
+ * ```html
4933
+ * <span navi-control> ← wrapper: root of the control's DOM subtree
4934
+ * <input navi-control-host /> ← host: holds controlProps, value, UI state, constraints
4935
+ * </span>
4936
+ * ```
4937
+ *
4938
+ * Attribute roles:
4939
+ * - `navi-control` boolean, on the wrapper/root; marks the control boundary
4940
+ * - `navi-control-host` boolean, on the host; set automatically by `useInteractiveProps`
4941
+ *
4942
+ * See control_proxy.js for the `navi-control-proxy-for` pattern.
4943
+ */
4944
+
4945
+ /**
4946
+ * Returns the host element inside `el` — the element that holds the control's
4947
+ * value, UI state, and constraints (i.e. the element onto which
4948
+ * `useInteractiveProps` spreads `controlProps` and its event handlers).
4949
+ *
4950
+ * Returns `null` when `el` is itself the host (no separate wrapper).
4951
+ */
4952
+ const findControlHost = (el) => {
4953
+ if (el.hasAttribute("navi-control-host")) {
4954
+ return el;
4955
+ }
4956
+ return el.querySelector("[navi-control-host]");
4957
+ };
4958
+ /**
4959
+ * The control this element belongs to, wherever it sits relative to it.
4960
+ *
4961
+ * `findControlHost` above answers for a control's own DOM (itself, or the native
4962
+ * element it wraps). This one is for something that is not part of a control but
4963
+ * has to reach one — an interaction declared on a box (see
4964
+ * interaction/interactions.js), which may be the control, may sit inside it, or
4965
+ * may be a wrapper around exactly one. Nearest wins in that order, and the answer
4966
+ * is null when there is no control the box belongs to: not every box lives in
4967
+ * one, and a box laying out several controls (a row of badges, a toolbar) is
4968
+ * held by none of them — its interactions are its own, and a press on its empty
4969
+ * part is not a press on the first control it happens to contain.
4970
+ */
4971
+ const findNearestControlHost = (el) => {
4972
+ // Itself, then upwards — the box is inside a button, or is one.
4973
+ const selfOrAncestor = el.closest("[navi-control-host]");
4974
+ if (selfOrAncestor) {
4975
+ return selfOrAncestor;
4976
+ }
4977
+ // …then downwards, only when the box wraps a single control. Counted by
4978
+ // control, not by host: a picker holds the hosts of its popup content, and a
4979
+ // box around that one picker is still around one control.
4980
+ let single = null;
4981
+ for (const host of el.querySelectorAll("[navi-control-host]")) {
4982
+ const controlRoot = host.closest("[navi-control]") || host;
4983
+ const controlAbove = controlRoot.parentElement.closest("[navi-control]");
4984
+ if (controlAbove && el.contains(controlAbove)) {
4985
+ continue;
4986
+ }
4987
+ if (single && single !== controlRoot) {
4988
+ return null;
4989
+ }
4990
+ single = controlRoot;
4991
+ }
4992
+ return single ? findControlHost(single) : null;
4993
+ };
4994
+ const isControlRoot = (el) => {
4995
+ return el.hasAttribute("navi-control");
4996
+ };
4997
+ const isControlHost = (el) => {
4998
+ return el.hasAttribute("navi-control-host");
4999
+ };
5000
+ const isControl = (el) => {
5001
+ return isControlRoot(el) || isControlHost(el);
5002
+ };
5003
+
5004
+ /**
5005
+ * Returns the nearest ancestor of `el` (exclusive of `el`'s own control) that
5006
+ * has a `[data-action]` attribute.
5007
+ *
5008
+ * The search walks up `parentElement` manually (rather than using `.closest()`)
5009
+ * so it can stop at hard boundaries.
5010
+ *
5011
+ * **`[navi-control="picker"]` boundary**: a picker is a hard stop for what is
5012
+ * inside its popup. Elements in there can reach the picker itself, but nothing
5013
+ * above it — an input inside a picker must not submit the form around it. The
5014
+ * picker's own trigger is not inside it: a picker pressed as a whole (a confirm
5015
+ * picker sending the form around it, see picker_confirm.jsx) stands in that
5016
+ * form like any control there, and reaches it.
5017
+ *
5018
+ * ```html
5019
+ * <form data-action="outer"> ← found from the picker's own input,
5020
+ * <button navi-control="picker" data-action="p"> NOT from inside its popup
5021
+ * <input navi-control-host /> ← el (the picker's own input)
5022
+ * <div popover data-picker-content>
5023
+ * <input navi-control-host /> ← el (in picker popover): stops at the picker
5024
+ * </div>
5025
+ * </button>
5026
+ * </form>
5027
+ * ```
5028
+ */
5029
+ const findClosestControlWithAction = (el) => {
5030
+ let current = el;
5031
+ while (current) {
5032
+ if (current.hasAttribute("data-action")) {
5033
+ return current;
5034
+ }
5035
+ if (isPickerBoundaryFor(current, el)) {
5036
+ return undefined;
5037
+ }
5038
+ current = current.parentElement;
5039
+ }
5040
+ return undefined;
5041
+ };
5042
+ const isPickerBoundaryFor = (candidate, el) => {
5043
+ if (candidate.getAttribute("navi-control") !== "picker") {
5044
+ return false;
5045
+ }
5046
+ const content = el.closest("[data-picker-content]");
5047
+ return Boolean(content) && candidate.contains(content);
5048
+ };
5049
+
5050
+ /**
5051
+ * Returns the closest ancestor control element of `el` — i.e. the nearest
5052
+ * `[navi-control]` element that is not the control `el` belongs to.
5053
+ *
5054
+ * `navi-control` is only on wrapper elements, never on hosts. So
5055
+ * `el.closest("[navi-control]")` from a host returns that host's own wrapper,
5056
+ * and one more `.parentNode.closest("[navi-control]")` reaches a true ancestor:
5057
+ *
5058
+ * ```html
5059
+ * <button navi-control> ← outer control (returned)
5060
+ * <span navi-control> ← inner wrapper (skipped via parentNode)
5061
+ * <input navi-control-host /> ← el
5062
+ * </span>
5063
+ * </button>
5064
+ * ```
5065
+ */
5066
+ const getParentControl = (el) => {
5067
+ const ownControlRoot = el.closest("[navi-control]");
5068
+ const parentControlRoot = ownControlRoot.parentNode.closest("[navi-control]");
5069
+ return parentControlRoot;
5070
+ };
5071
+
5072
+ /**
5073
+ * Returns the root element of the control that `el` belongs to, or `null` if
5074
+ * `el` is not part of a control.
5075
+ *
5076
+ * Use this when you have an element that may be a host (inner input) and need
5077
+ * the visual boundary of its control — e.g. to anchor a callout, track
5078
+ * mousedown interactions, or measure the control's bounding box.
5079
+ */
5080
+ const findControlRoot = (el) => {
5081
+ if (el.hasAttribute("navi-control")) {
5082
+ return el;
5083
+ }
5084
+ if (el.hasAttribute("navi-control-host")) {
5085
+ return el.closest("[navi-control]");
5086
+ }
5087
+ return null;
5088
+ };
5089
+
5090
+ /**
5091
+ * Parses a time string into seconds.
5092
+ * Accepts:
5093
+ * - number: returned as-is (already in seconds)
5094
+ * - "HH:MM" string: converted to seconds (e.g. "00:30" → 1800, "01:00" → 3600)
5095
+ * - undefined/null: returned as-is
5096
+ */
5097
+ const timeStringToSeconds = (timeString) => {
5098
+ if (typeof timeString !== "string") {
5099
+ return timeString;
5100
+ }
5101
+ const colonIndex = timeString.indexOf(":");
5102
+ if (colonIndex === -1) {
5103
+ return Number(timeString);
5104
+ }
5105
+ const hours = parseInt(timeString.slice(0, colonIndex), 10);
5106
+ const minutes = parseInt(timeString.slice(colonIndex + 1), 10);
5107
+ return (hours * 60 + minutes) * 60;
5108
+ };
5109
+
5110
+ const isToday = (value) => {
5111
+ if (!value) {
5112
+ return false;
5113
+ }
5114
+ const now = new Date();
5115
+ const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
5116
+ if (typeof value === "string") {
5117
+ return value === todayStr;
5118
+ }
5119
+ if (typeof value === "number") {
5120
+ const d = new Date(value);
5121
+ const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
5122
+ return s === todayStr;
5123
+ }
5124
+ if (value instanceof Date) {
5125
+ const s = `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
5126
+ return s === todayStr;
5127
+ }
5128
+ return false;
5129
+ };
5130
+
5131
+ /**
5132
+ * Returns the current time as "HH:MM", with an optional minute offset.
5133
+ *
5134
+ * @param {number} [offsetMinutes=0] - Minutes to add (negative = subtract).
5135
+ * E.g. getNowHours(-5) returns "now minus 5 minutes".
5136
+ *
5137
+ * @example
5138
+ * getNowHours() // "14:30"
5139
+ * getNowHours(-5) // "14:25"
5140
+ */
5141
+ const getNowHours = (offsetMinutes = 0) => {
5142
+ const now = new Date();
5143
+ const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
5144
+ const clamped =
5145
+ totalMinutes < 0
5146
+ ? 0
5147
+ : totalMinutes > 23 * 60 + 59
5148
+ ? 23 * 60 + 59
5149
+ : totalMinutes;
5150
+ const h = Math.floor(clamped / 60);
5151
+ const m = clamped % 60;
5152
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
5153
+ };
5154
+
5155
+ /**
5156
+ * Returns the current time rounded up to the nearest step boundary,
5157
+ * with an optional minute offset applied first.
5158
+ *
5159
+ * This is useful to compute a step-aligned `min` for a time picker:
5160
+ * passing it ensures the first available slot is always on a step boundary.
5161
+ *
5162
+ * @param {number} stepMinutes - Step size in minutes (e.g. 30).
5163
+ * @param {number} [offsetMinutes=0] - Minutes to add before rounding (negative = subtract).
5164
+ *
5165
+ * @example
5166
+ * // At 9:32, step 30, offset -5 → raw = 9:27 → ceil to 30 → "09:30"
5167
+ * // At 9:38, step 30, offset -5 → raw = 9:33 → ceil to 30 → "10:00"
5168
+ * getNowHoursRoundedToStep(30, -5)
5169
+ */
5170
+ const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
5171
+ const now = new Date();
5172
+ const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
5173
+ const aligned = Math.ceil(totalMinutes / stepMinutes) * stepMinutes;
5174
+ const clamped =
5175
+ aligned < 0 ? 0 : aligned > 23 * 60 + 59 ? 23 * 60 + 59 : aligned;
5176
+ const h = Math.floor(clamped / 60);
5177
+ const m = clamped % 60;
5178
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
5179
+ };
5180
+
5181
+ /**
5182
+ * "HH:MM" and its two numbers, in both directions — what any control made of an
5183
+ * hour beside a minute (fields, wheels) aggregates to and is placed from. Held
5184
+ * as numbers, written on two digits: how they are shown is each control's own
5185
+ * business.
5186
+ */
5187
+ const parseTimeParts = (time) => {
5188
+ if (typeof time !== "string") {
5189
+ return null;
5190
+ }
5191
+ const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
5192
+ if (!match) {
5193
+ return null;
5194
+ }
5195
+ return { hour: Number(match[1]), minute: Number(match[2]) };
5196
+ };
5197
+
5198
+ // Half a time is not a time: a control holding one of the two and nothing in
5199
+ // the other has no value at all, and a form has nothing to send about it.
5200
+ const formatTimeParts = (hour, minute) => {
5201
+ if (
5202
+ hour === "" ||
5203
+ hour === undefined ||
5204
+ minute === "" ||
5205
+ minute === undefined
5206
+ ) {
5207
+ return undefined;
5208
+ }
5209
+ return `${padTwo$1(hour)}:${padTwo$1(minute)}`;
5210
+ };
5211
+
5212
+ const minutesFromTime$1 = (time) => {
5213
+ const parts = parseTimeParts(time);
5214
+ if (!parts) {
5215
+ return null;
5216
+ }
5217
+ return parts.hour * 60 + parts.minute;
5218
+ };
5219
+
5220
+ // Not folded back into the day: 1440 is "24:00", the end of the day a span can
5221
+ // run into.
5222
+ const timeFromMinutes = (minutes) => {
5223
+ return `${padTwo$1(Math.floor(minutes / 60))}:${padTwo$1(minutes % 60)}`;
5224
+ };
5225
+
5226
+ const padTwo$1 = (value) => String(value).padStart(2, "0");
5227
+
5228
+ // What a bound signal's type says is what its VALUE is (a validity type);
5229
+ // what a control's `type` says is what the control IS. This table is the whole
5230
+ // bridge between the two vocabularies: a value type with a row here asks for
5231
+ // that control, one without a row asks for nothing and the control keeps its
5232
+ // own default. "string" has no row on purpose — it is the value every typeless
5233
+ // control already holds — and a name only one side knows must never cross as
5234
+ // is: a control type nobody implements is a plain text field that writes an
5235
+ // object as "[object Object]" (see warnOnUnknownPickerType in picker.jsx).
5236
+ // Numeric value types map to navi_number rather than the native type="number"
5237
+ // (spinner buttons, poor UX).
5238
+ const CONTROL_TYPE_BY_SIGNAL_TYPE = {
5239
+ boolean: "checkbox",
5240
+ number: "navi_number",
5241
+ integer: "navi_number",
5242
+ float: "navi_number",
5243
+ ratio: "navi_number",
5244
+ longitude: "navi_number",
5245
+ latitude: "navi_number",
5246
+ second: "navi_number",
5247
+ minute: "navi_number",
5248
+ hour: "navi_number",
5249
+ year: "navi_number",
5250
+ percentage: "navi_percentage",
5251
+ date: "date",
5252
+ month: "month",
5253
+ week: "week",
5254
+ time: "time",
5255
+ datetime: "datetime",
5256
+ duration: "duration",
5257
+ color: "color",
5258
+ email: "email",
5259
+ url: "url",
5260
+ array: "array",
5261
+ object: "object",
5262
+ };
5263
+
5264
+ // Conceptual navi types: defaults, plus the host type they resolve to.
5265
+ // `navi-input-type` keeps the type the caller asked for once the host has
5266
+ // become something plainer — it is what says the value is a number (see
5267
+ // isNumberInput), and what lets constraint messages use domain-specific
5268
+ // wording instead of the generic "Ce nombre doit être...".
5269
+ // A type a picker preset draws (navi_time, navi_minute: see picker_preset.jsx)
5270
+ // has no row: resolveInputProps runs before the preset resolver, and a row
5271
+ // would turn the type into its host before the preset resolver reads it. The
5272
+ // preset hands the host type on itself.
5273
+ const NAVI_TYPE_DEFAULTS = {
5274
+ navi_percentage: {
5275
+ "type": "navi_number",
5276
+ "navi-input-type": "percentage",
5277
+ "min": 0,
5278
+ "max": 100,
5279
+ "step": 1,
5280
+ },
5281
+ navi_number: {
5282
+ "type": "text",
5283
+ "navi-input-type": "number",
5284
+ "autoCorrect": "off",
5285
+ "spellcheck": false,
5286
+ "autoComplete": "off",
5287
+ },
5288
+ };
5289
+
5290
+ // The navi input types whose value IS a number.
5291
+ const NUMBER_NAVI_INPUT_TYPE_SET = new Set([
5292
+ "number",
5293
+ "percentage",
5294
+ "hour",
5295
+ "minute",
5296
+ "second",
5297
+ ]);
5298
+
5299
+ /**
5300
+ * Whether the control holds a number — asked of what the control IS, never of
5301
+ * `inputMode`. In HTML `inputmode` picks the on-screen keyboard and says
5302
+ * nothing about the value: a licence number, a postal code or a card number
5303
+ * with a check letter all want the digit keypad while staying strings. What a
5304
+ * number field is spelled `type="number"`, or a navi type that resolves to a
5305
+ * text host and leaves `navi-input-type` behind to say what it was.
5306
+ */
5307
+ const isNumberInput = (type, naviInputType) => {
5308
+ if (type === "number") {
5309
+ return true;
5310
+ }
5311
+ return NUMBER_NAVI_INPUT_TYPE_SET.has(naviInputType);
5312
+ };
5313
+
5314
+ /**
5315
+ * resolveInputProps — normalizes input-related props that are shared across
5316
+ * `<Picker>`, `<Input>` (textual) and `<Range>`. Mutates the props object in place.
5317
+ *
5318
+ * Normalization is applied recursively: a navi type may resolve to another navi
5319
+ * type (e.g. `navi_percentage` → `navi_number` → `text`), and each step applies its
5320
+ * own formatters and defaults before moving to the next.
5321
+ *
5322
+ * Steps applied for each type:
5323
+ * 1. Record the original navi type in `props["navi-input-type"]` (first call only).
5324
+ * 2. Apply defaults for the current type (min, max, step, and any other props),
5325
+ * only when the prop is not already set.
5326
+ * 3. Apply min/max formatters (e.g. HH:MM string → number for duration types,
5327
+ * Date → formatted string for date/time types).
5328
+ * 4. Apply step formatter (same conversion rules).
5329
+ * 5. Remap `props.type` to the target type defined by the current type's defaults,
5330
+ * then recurse.
5331
+ *
5332
+ * Supported navi types and their targets:
5333
+ * - `navi_percentage` → `navi_number` (0–100, step 1)
5334
+ * - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
5335
+ *
5336
+ * Standard HTML input types with formatters:
5337
+ * - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
5338
+ * min/max accept `Date` instances or timestamps and are converted to the
5339
+ * string format expected by the native input.
5340
+ * - `time`, `datetime-local`, `datetime`:
5341
+ * step accepts `"HH:MM"` and is converted to seconds.
5342
+ */
5343
+ /**
5344
+ * A bound signal that carries a default of its own says the same thing on every
5345
+ * control: the control starts there, uncontrolled, and a reset goes back to it.
5346
+ * Uncontrolled here is about where the control starts, not about whether it
5347
+ * follows the signal — the binding stays two-way either way (see
5348
+ * stateFromSignal in control_hooks.jsx) — nor about what a form counts as
5349
+ * held: that is the signal's content (isUIStateHeld in held_ui_state.js), and a
5350
+ * stateSignal holds its default. Written once and used by everything
5351
+ * that takes a `signal`, so one signal cannot mean two different things
5352
+ * depending on which control it was handed to.
5353
+ */
5354
+ const seedDefaultValueFromSignal = (props) => {
5355
+ const signalOptions = props.signal?.options;
5356
+ if (!signalOptions) {
5357
+ return;
5358
+ }
5359
+ if (Object.hasOwn(props, "defaultValue")) {
5360
+ // explicit defaultValue prop prevails
5361
+ return;
5362
+ }
5363
+ // Snapshot the signal's current default so that resetUIState restores to the
5364
+ // original default — not the value the signal had at the time of the last
5365
+ // re-render.
5366
+ const defaultValue = signalOptions.getDefaultValue(false);
5367
+ if (defaultValue !== undefined) {
5368
+ props.defaultValue = defaultValue;
5369
+ }
5370
+ };
5371
+
5372
+ const resolveInputProps = (props, { controlType = "input" } = {}) => {
5373
+ // `signal` carries a bound state signal. It is left on `props` on purpose:
5374
+ // `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
5375
+ // follow it, and `onUIAction` (ui_state_controller.js) writes user
5376
+ // interactions back into it. Here we only derive input defaults (type/min/max,
5377
+ // defaultValue/defaultChecked) from the signal's `options`, so the control
5378
+ // ends up uncontrolled-with-default while still bound to the signal.
5379
+ const signal = props.signal;
5380
+ if (signal) {
5381
+ const signalOptions = signal.options;
5382
+ if (signalOptions) {
5383
+ for (const key of ["min", "max", "step"]) {
5384
+ if (props[key] === undefined && signalOptions[key] !== undefined) {
5385
+ props[key] = signalOptions[key];
5386
+ }
5387
+ }
5388
+ if (props.type === undefined && signalOptions.type !== undefined) {
5389
+ const typeFromSignal = CONTROL_TYPE_BY_SIGNAL_TYPE[signalOptions.type];
5390
+ // A value type and the control for it usually agree — a date-typed
5391
+ // signal wants a date field — but a boolean one maps to a checkbox, and
5392
+ // a picker made into a checkbox is not a picker with a different look:
5393
+ // it is another control, with no popup to open. A picker asked to hold
5394
+ // a yes/no keeps its two rows and stays itself.
5395
+ const wouldChangeWhatTheControlIs =
5396
+ controlType === "picker" &&
5397
+ (typeFromSignal === "checkbox" || typeFromSignal === "radio");
5398
+ if (typeFromSignal !== undefined && !wouldChangeWhatTheControlIs) {
5399
+ props.type = typeFromSignal;
5400
+ }
5401
+ }
5402
+ }
5403
+
5404
+ const isCheckable = props.type === "checkbox" || props.type === "radio";
5405
+ if (isCheckable) {
5406
+ if (Object.hasOwn(props, "defaultChecked")) ; else {
5407
+ // If no explicit defaultChecked, derive it from the signal's default
5408
+ // value so that resetUIState restores to the original default.
5409
+ // Only a stateSignal carries a default of its own; a plain signal has
5410
+ // no `options` at all, hence the optional read.
5411
+ const defaultVal = signalOptions?.getDefaultValue(false);
5412
+ if (defaultVal === undefined) ; else if (props.type === "radio") {
5413
+ if (defaultVal === true) {
5414
+ props.defaultChecked = true;
5415
+ } else if (
5416
+ Object.hasOwn(props, "value") &&
5417
+ defaultVal === props.value
5418
+ ) {
5419
+ props.defaultChecked = true;
5420
+ }
5421
+ } else if (typeof defaultVal === "boolean") {
5422
+ // Standalone checkbox bound to a boolean signal.
5423
+ props.defaultChecked = defaultVal;
5424
+ } else {
5425
+ // Checkbox is a group member: defaultVal is the array of
5426
+ // selected item values.
5427
+ const checkboxValue = props.value;
5428
+ props.defaultChecked =
5429
+ Array.isArray(defaultVal) && defaultVal.includes(checkboxValue);
5430
+ }
5431
+ }
5432
+ return;
5433
+ }
5434
+
5435
+ seedDefaultValueFromSignal(props);
5436
+ }
5437
+
5438
+ const currentType = props.type;
5439
+ // Apply min/max/step formatters before anything else — this must run even for
5440
+ // standard HTML types (date, time, etc.) that have no NAVI_TYPE_DEFAULTS entry.
5441
+ const currentTypeMinMaxFormatter = MIN_MAX_FORMATTER_BY_TYPE[currentType];
5442
+ const currentTypeStepFormatter = STEP_FORMATTER_BY_TYPE[currentType];
5443
+ if (currentTypeMinMaxFormatter) {
5444
+ props.min = currentTypeMinMaxFormatter(props.min);
5445
+ props.max = currentTypeMinMaxFormatter(props.max);
5446
+ }
5447
+ if (currentTypeStepFormatter) {
5448
+ props.step = currentTypeStepFormatter(props.step);
5449
+ }
5450
+
5451
+ // For navi_number: choose inputMode based on whether step/min/max suggest decimals.
5452
+ // inputMode="numeric" (integer keyboard) vs "decimal" (keyboard with decimal separator).
5453
+ if (currentType === "navi_number") {
5454
+ if (props.inputMode === undefined) {
5455
+ props.inputMode =
5456
+ hasDecimalPlaces(props.step) ||
5457
+ hasDecimalPlaces(props.min) ||
5458
+ hasDecimalPlaces(props.max)
5459
+ ? "decimal"
5460
+ : "numeric";
5461
+ }
5462
+ }
5463
+
5464
+ const { charGuard } = props;
5465
+ if (charGuard) {
5466
+ if (charGuard === true || charGuard === "auto") {
5467
+ // Auto-resolve charGuard from context.
5468
+ let charGuardResolved;
5469
+ const inputMode = props.inputMode;
5470
+ if (inputMode === "numeric") {
5471
+ charGuardResolved = "numeric";
5472
+ } else if (inputMode === "decimal") {
5473
+ charGuardResolved = "decimal";
5474
+ } else if (currentType === "tel") {
5475
+ charGuardResolved = "tel";
5476
+ } else if (currentType === "email") {
5477
+ charGuardResolved = "email";
5478
+ }
5479
+ if (charGuardResolved !== undefined) {
5480
+ props.charGuard = charGuardResolved;
5481
+ }
5482
+ }
5483
+ // charGuard is now resolved: derive inputMode from it if not already set.
5484
+ if (props.inputMode === undefined && props.charGuard) {
5485
+ const autoMode = INPUT_MODE_FROM_CHAR_GUARD[props.charGuard];
5486
+ if (autoMode) {
5487
+ props.inputMode = autoMode;
5488
+ }
5489
+ }
5490
+ // Build pattern from the resolved charGuard (preset name → class, or raw class passthrough).
5491
+ if (props.pattern === undefined && props.charGuard) {
5492
+ const charClass = CHAR_CLASS_PRESETS[props.charGuard] ?? props.charGuard;
5493
+ props.pattern = `${charClass}*`;
5494
+ }
5495
+ }
5496
+
5497
+ // Compute maxLength from max when inputMode is numeric/decimal.
5498
+ // Done here (after inputMode is set) so controller.props has the resolved value.
5499
+ if (props.maxLength === undefined) {
5500
+ if (props.inputMode === "numeric") {
5501
+ const { min, max } = props;
5502
+ if (max === undefined) ; else {
5503
+ const canBeNegative = min === undefined ? max < 0 : min < 0;
5504
+ const signCharCount = canBeNegative ? 1 : 0;
5505
+ const integerDigitCount = String(Math.floor(Math.abs(max))).length;
5506
+ props.maxLength = signCharCount + integerDigitCount;
5507
+ }
5508
+ } else if (props.inputMode === "decimal") {
5509
+ const { min, max, step } = props;
5510
+ if (max === undefined) ; else if (step === undefined) ; else {
5511
+ const canBeNegative = min === undefined ? max < 0 : min < 0;
5512
+ const signCharCount = canBeNegative ? 1 : 0;
5513
+ const integerDigitCount = String(Math.floor(Math.abs(max))).length;
5514
+ const stepStr = String(step);
5515
+ const dotIndex = stepStr.indexOf(".");
5516
+ // integer step + decimal inputMode is an unusual combo, but we stay consistent:
5517
+ // no decimal part in maxLength since valid values are whole numbers anyway
5518
+ const isIntegerStep = dotIndex === -1;
5519
+ const decimalSignCharCount = isIntegerStep ? 0 : 1;
5520
+ const decimalDigitCount = isIntegerStep
5521
+ ? 0
5522
+ : stepStr.length - dotIndex - 1;
5523
+ props.maxLength =
5524
+ signCharCount +
5525
+ integerDigitCount +
5526
+ decimalSignCharCount +
5527
+ decimalDigitCount;
5528
+ }
5529
+ }
5530
+ }
5531
+
5532
+ // Resolve maxLengthGuard boolean/auto → the computed maxLength number.
5533
+ if (props.maxLengthGuard === true || props.maxLengthGuard === "auto") {
5534
+ props.maxLengthGuard =
5535
+ typeof props.maxLength === "number" ? props.maxLength : undefined;
5536
+ }
5537
+
5538
+ const currentTypeDefaults = NAVI_TYPE_DEFAULTS[currentType];
5539
+ if (!currentTypeDefaults) {
5540
+ return;
5541
+ }
5542
+
5543
+ for (const key of Object.keys(currentTypeDefaults)) {
5544
+ if (props[key] === undefined) {
5545
+ props[key] = currentTypeDefaults[key];
5546
+ }
5547
+ }
5548
+ const targetType = currentTypeDefaults.type;
5549
+ props.type = targetType;
5550
+ resolveInputProps(props);
5551
+ };
5552
+
5553
+ // Presets that imply a specific mobile keyboard inputMode.
5554
+ const INPUT_MODE_FROM_CHAR_GUARD = {
5555
+ numeric: "numeric",
5556
+ pin: "numeric",
5557
+ card: "numeric",
5558
+ tel: "tel",
5559
+ decimal: "decimal",
5560
+ };
5561
+
5562
+ const normalizeToDate = (value) => {
5563
+ if (value === undefined || value === null) {
5564
+ return null;
5565
+ }
5566
+ if (typeof value === "number") {
5567
+ return new Date(value);
5568
+ }
5569
+ if (value instanceof Date) {
5570
+ return value;
5571
+ }
5572
+ return null;
5573
+ };
5574
+
5575
+ const toInputDate = (value) => {
5576
+ const date = normalizeToDate(value);
5577
+ if (!date) {
5578
+ return value;
5579
+ }
5580
+ const yyyy = date.getFullYear();
5581
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
5582
+ const dd = String(date.getDate()).padStart(2, "0");
5583
+ return `${yyyy}-${mm}-${dd}`;
5584
+ };
5585
+ const toInputMonth = (value) => {
5586
+ const date = normalizeToDate(value);
5587
+ if (!date) {
5588
+ return value;
5589
+ }
5590
+ const yyyy = date.getFullYear();
5591
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
5592
+ return `${yyyy}-${mm}`;
5593
+ };
5594
+ const toInputWeek = (value) => {
5595
+ const date = normalizeToDate(value);
5596
+ if (!date) {
5597
+ return value;
5598
+ }
5599
+ // ISO week number
5600
+ const d = new Date(date);
5601
+ d.setHours(0, 0, 0, 0);
5602
+ d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
5603
+ const yearStart = new Date(d.getFullYear(), 0, 4);
5604
+ const week =
5605
+ Math.round(
5606
+ ((d - yearStart) / 86400000 - 3 + ((yearStart.getDay() + 6) % 7)) / 7,
5607
+ ) + 1;
5608
+ return `${d.getFullYear()}-W${String(week).padStart(2, "0")}`;
5609
+ };
5610
+ const toInputTime = (value) => {
5611
+ const date = normalizeToDate(value);
5612
+ if (!date) {
5613
+ return value;
5614
+ }
5615
+ const hh = String(date.getHours()).padStart(2, "0");
5616
+ const mm = String(date.getMinutes()).padStart(2, "0");
5617
+ return `${hh}:${mm}`;
5618
+ };
5619
+ const toInputDatetime = (value) => {
5620
+ const date = normalizeToDate(value);
5621
+ if (!date) {
5622
+ return value;
5623
+ }
5624
+ const yyyy = date.getFullYear();
5625
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
5626
+ const dd = String(date.getDate()).padStart(2, "0");
5627
+ const hh = String(date.getHours()).padStart(2, "0");
5628
+ const min = String(date.getMinutes()).padStart(2, "0");
5629
+ return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
5630
+ };
5631
+
5632
+ const MIN_MAX_FORMATTER_BY_TYPE = {
5633
+ "date": toInputDate,
5634
+ "month": toInputMonth,
5635
+ "week": toInputWeek,
5636
+ "time": toInputTime,
5637
+ "datetime-local": toInputDatetime,
5638
+ "datetime": toInputDatetime,
5639
+ };
5640
+ const STEP_FORMATTER_BY_TYPE = {
5641
+ "time": timeStringToSeconds,
5642
+ "datetime-local": timeStringToSeconds,
5643
+ "datetime": timeStringToSeconds,
5644
+ };
5645
+
5646
+ const hasDecimalPlaces = (value) => {
5647
+ if (value === undefined || value === null) {
5648
+ return false;
5649
+ }
5650
+ const num = Number(value);
5651
+ return !isNaN(num) && !Number.isInteger(num);
5652
+ };
5653
+
5654
+ const dispatchRequestSetUIState = (element, value, detail) => {
5655
+ const controlHost = findControlHost(element) || element;
5656
+ return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
5657
+ ...detail,
5658
+ value,
5659
+ });
5660
+ };
5661
+ const dispatchRequestClearUIState = (element, e) => {
5662
+ const controlHost = findControlHost(element) || element;
5663
+ return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
5664
+ event: e,
5665
+ });
5666
+ };
5667
+ const dispatchRequestResetUIState = (element, e) => {
5668
+ const controlHost = findControlHost(element) || element;
5669
+ return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
5670
+ event: e,
5671
+ });
5672
+ };
5673
+ /**
5674
+ * @param {Element} el
5675
+ * @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
5676
+ * Only a button ever answers differently — one with no value of its own
5677
+ * inherits the value of the control around it, which is what makes
5678
+ * `--navi-send` on a form's button be about that form. Something asking what
5679
+ * THIS element says (a travel command reading what the travel is about) wants
5680
+ * the own value and would otherwise be handed the surrounding control's.
5681
+ */
5682
+ const getUIStateFromElement = (el, { own } = {}) => {
5683
+ let uiState;
5684
+ dispatchInternalCustomEvent(el, "navi_get_ui_state", {
5685
+ own,
5686
+ respondWith: (v) => {
5687
+ uiState = v;
5688
+ },
5689
+ });
5690
+ return uiState;
5691
+ };
5692
+
5693
+ /**
5694
+ * Converts a JS value into the form expected by the browser DOM property for a
5695
+ * given control type/input type combination.
5696
+ *
5697
+ * For example:
5698
+ * - `datetime-local` inputs expect a local datetime string without timezone
5699
+ * - `number`/`range` inputs expect a numeric string or number
5700
+ * - `color` inputs require a non-empty hex string (falls back to `#000000`)
5701
+ * - All other inputs receive the value as-is (undefined → "")
5702
+ *
5703
+ * Returns either the converted value directly, or a converter function when the
5704
+ * conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
5705
+ *
5706
+ * @param {any} value - The JS value to convert.
5707
+ * @param {{ controlType: string, type: string }} options
5708
+ * @returns {any} The DOM-compatible value or a converter function.
5709
+ */
5710
+ const asControlHostValue = (
5711
+ jsValue,
5712
+ { controlType, type, naviInputType, pad },
5713
+ ) => {
5714
+ if (controlType === "select") {
5715
+ // A select holds one of its options, always a string; holding nothing is
5716
+ // the empty option, which the element spells "".
5717
+ return asInputValue(jsValue);
5718
+ }
5719
+ if (controlType === "input" || controlType === "picker") {
5720
+ if (type === "datetime-local") {
5721
+ return asDatetimeLocalString(jsValue);
5722
+ }
5723
+ if (type === "range" || isNumberInput(type, naviInputType)) {
5724
+ return asNumberString(jsValue, pad);
5725
+ }
5726
+ if (type === "color") {
5727
+ return asColorString(jsValue);
5728
+ }
5729
+ return asInputValue(jsValue);
5730
+ }
5731
+ return jsValue;
5732
+ };
5733
+ // As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
5734
+ // datetime-local does not support timezones
5735
+ const asDatetimeLocalString = (dateTimeString) => {
5736
+ const date = new Date(dateTimeString);
5737
+ if (isNaN(date.getTime())) {
5738
+ return dateTimeString;
5739
+ }
5740
+ const year = date.getFullYear();
5741
+ const month = String(date.getMonth() + 1).padStart(2, "0");
5742
+ const day = String(date.getDate()).padStart(2, "0");
5743
+ const hours = String(date.getHours()).padStart(2, "0");
5744
+ const minutes = String(date.getMinutes()).padStart(2, "0");
5745
+ const seconds = String(date.getSeconds()).padStart(2, "0");
5746
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
5747
+ };
5748
+ // `pad` is how many digits the number is WRITTEN on — an hour is held as 7 and
5749
+ // shown as "07". Held and shown are two things here, the way they are for a
5750
+ // datetime-local above: what the field says is derived from what the control
5751
+ // holds, and reading it back (readNumberFromInput) gives the number again.
5752
+ const asNumberString = (jsValue, pad) => {
5753
+ if (jsValue === undefined) {
5754
+ return "";
5755
+ }
5756
+ if (!pad || jsValue === "" || jsValue === null) {
5757
+ return jsValue;
5758
+ }
5759
+ const number = Number(jsValue);
5760
+ if (Number.isNaN(number)) {
5761
+ return jsValue;
5762
+ }
5763
+ const negative = number < 0;
5764
+ const digits = String(negative ? -number : number).padStart(Number(pad), "0");
5765
+ return negative ? `-${digits}` : digits;
5766
+ };
5767
+ // Browser requires a non-empty value for <input type="color">.
5768
+ // When our logical value is empty we give it #000000 so it doesn't choke.
5769
+ // The UI uses the original (possibly empty) value to show the checkerboard.
5770
+ const asColorString = (jsValue) => {
5771
+ return jsValue || "#000000";
5772
+ };
5773
+ const asInputValue = (jsValue) => {
5774
+ if (jsValue === undefined) {
5775
+ return "";
5776
+ }
5777
+ return jsValue;
5778
+ };
5779
+
5780
+ /**
5781
+ * Reads the current logical JS value from a control host DOM element.
5782
+ *
5783
+ * Handles all navi control host element types:
5784
+ * - `<button>` — reads via `navi_get_value` custom event, falls back to `button.value`
5785
+ * - `<input type="number|range">` — parses as a number, returns `undefined` when empty
5786
+ * - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
5787
+ * via `navi_get_value` custom event (to preserve the original JS type of the value prop)
5788
+ * - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
5789
+ * - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
5790
+ * - All other inputs — returns `input.value` as a string
5791
+ *
5792
+ * @param {HTMLElement} controlHost - The control host DOM element to read from.
5793
+ * @returns {any} The current logical value of the control.
5794
+ */
5795
+ const readControlValue = (controlHost) => {
5796
+ if (
5797
+ controlHost.tagName === "BUTTON" ||
5798
+ controlHost.getAttribute("role") === "button"
5799
+ ) {
5800
+ return readValueFromButton(controlHost);
5801
+ }
5802
+ if (controlHost.tagName === "INPUT") {
5803
+ // important: input.type = "navi_js"; followed by input.type; returns "text"
5804
+ // so use getAttribute
5805
+ const type = controlHost.getAttribute("type");
5806
+
5807
+ if (
5808
+ type === "range" ||
5809
+ isNumberInput(type, controlHost.getAttribute("navi-input-type"))
5810
+ ) {
5811
+ return readNumberFromInput(controlHost);
5812
+ }
5813
+ if (type === "color") {
5814
+ return readValueFromControlHost(controlHost);
5815
+ }
5816
+ if (type === "checkbox" || type === "radio") {
5817
+ return readValueFromCheckableInput(controlHost);
5818
+ }
5819
+ if (type === "datetime-local") {
5820
+ return readDatetimeLocalFromInput(controlHost);
5821
+ }
5822
+ if (type === "navi_js") {
5823
+ return getUIStateFromElement(controlHost);
5824
+ }
5825
+ return readValueFromInput(controlHost);
5826
+ }
5827
+ if (controlHost.hasAttribute("navi-control-host")) {
5828
+ // Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
5829
+ return readValueFromControlHost(controlHost);
5830
+ }
5831
+ return readValueFromElement(controlHost);
5832
+ };
5833
+ const readValueFromControlHost = (controlHost) => {
5834
+ return readValueFromNaviCustomEvent(controlHost, controlHost.value);
5835
+ };
5836
+ const readValueFromButton = (button) => {
5837
+ return readValueFromControlHost(button);
5838
+ };
5839
+ const readDatetimeLocalFromInput = (input) => {
5840
+ const localDateTimeString = input.value;
5841
+ if (localDateTimeString === "") {
5842
+ return "";
5843
+ }
5844
+ const localDate = new Date(localDateTimeString);
5845
+ if (isNaN(localDate.getTime())) {
5846
+ return localDateTimeString;
5847
+ }
5848
+ return localDate.toISOString();
5849
+ };
5850
+ const readNumberFromInput = (input) => {
5851
+ const numberString = input.value;
5852
+ if (numberString === "") {
5853
+ return "";
5854
+ }
5855
+ const asNumber = Number(numberString);
5856
+ if (isNaN(asNumber)) {
5857
+ return numberString;
5858
+ }
5859
+ return asNumber;
5860
+ };
5861
+ const readValueFromCheckableInput = (input) => {
5862
+ const checked = input.checked;
5863
+ if (!checked) {
5864
+ return undefined;
5865
+ }
5866
+ return readValueFromControlHost(input);
5867
+ };
5868
+ const readValueFromInput = (input) => {
5869
+ const value = input.value;
5870
+ return value;
5871
+ };
5872
+ const readValueFromElement = (element) => {
5873
+ const value = element.value;
5874
+ return value;
5875
+ };
5876
+ const readValueFromNaviCustomEvent = (field, fallback) => {
5877
+ // prefer the value given as prop (respect original type, browser would convert to string)
5878
+ let responded;
5879
+ let value;
5880
+ dispatchCustomEvent(field, "navi_get_value", {
5881
+ respondWith: (jsValue) => {
5882
+ responded = true;
5883
+ value = jsValue;
5884
+ },
5885
+ });
5886
+ if (responded) {
5887
+ return value;
5888
+ }
5889
+ return fallback;
5890
+ };
5891
+
5892
+ /**
5893
+ * A control is bound to EITHER a signal (two-way) or a value/checked prop,
5894
+ * never both — the two would fight over the state. The signal is the one that
5895
+ * wins; this says so in dev, wherever the question is decided (a leaf control
5896
+ * in control_hooks, a group in ui_state_controller).
5897
+ *
5898
+ * Only the controlled prop (value/checked) collides with signal — the default
5899
+ * prop (defaultValue/defaultChecked) is legitimately seeded from the signal by
5900
+ * resolveInputProps, so it must not warn here.
5901
+ */
5902
+ const warnSignalCollision = (props, controlType, stateProp) => {
5903
+ if (Object.hasOwn(props, stateProp)) {
5904
+ console.warn(
5905
+ `[navi] "${controlType}" got both "signal" and "${stateProp}". ` +
5906
+ `"signal" is the source of truth; "${stateProp}" is ignored. ` +
5907
+ `Pass only "signal".`,
5908
+ );
5909
+ }
5910
+ };
5911
+
5912
+ /**
5913
+ * A signal handed to a prop that holds a plain value is the `signal` binding
5914
+ * written in the wrong place. Nothing throws: the signal object becomes the
5915
+ * value, and a value that cannot be written to the DOM is replaced by a
5916
+ * `window.__navi_js('…')` reference string — which is then what the field
5917
+ * shows on screen (see toDomValue in controller_registry.js).
5918
+ */
5919
+ const warnSignalAsState = (props, controlType, stateProps) => {
5920
+ for (const stateProp of stateProps) {
5921
+ if (isSignal(props[stateProp])) {
5922
+ console.warn(
5923
+ `[navi] "${controlType}" got a signal as "${stateProp}". ` +
5924
+ `A signal binds a control through "signal" — did you mean signal={...}? ` +
5925
+ `As "${stateProp}" it is taken for the value itself.`,
5926
+ );
5927
+ }
5928
+ }
5929
+ };
5930
+
5931
+ // In-memory registry of all mounted ui state controllers keyed by their id.
5932
+ // Allows direct controller access without dispatching DOM events — used by external
5933
+ // callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
5934
+ const controllersById = new Map();
5935
+
5936
+ // In-memory registry for radio controllers, keyed by input name.
5937
+ // Allows radio sibling unchecking without querying the DOM — necessary when
5938
+ // items are virtualized and their DOM element may not exist at the time.
5939
+ // Form scoping is reproduced by comparing parentUIStateController references.
5940
+ const radioControllersByName = new Map();
5941
+
5942
+ // Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
5943
+ // When a value is an object/array, we store it here and write a reference string to the DOM
5944
+ // instead of "[object Object]". Console-inspectable via window.__navi_js('id').
5945
+ // The controller id is used as key — if the controller has no id, the value is not registered.
5946
+ const naviJsRegistry = new Map();
5947
+
5948
+ // Controllers already reported for sharing their id with another one. An id is
5949
+ // stated on every render of its control, so without this the warning below
5950
+ // would repeat for as long as both controls are on screen.
5951
+ const controllersWarned = new WeakSet();
5952
+
5953
+ const getUIStateControllerById = (id) => controllersById.get(id);
5954
+ const getRadioSiblings = (radioUIStateController) => {
5955
+ const siblings = radioControllersByName.get(radioUIStateController.name);
5956
+ return siblings;
5957
+ };
5958
+
5959
+ const toDomValue = (
5960
+ jsValue,
5961
+ { controlType, id, type, naviInputType, pad },
5962
+ ) => {
5963
+ const domValue = asControlHostValue(jsValue, {
5964
+ controlType,
5965
+ type,
5966
+ naviInputType,
5967
+ pad,
5968
+ });
5969
+ if (isSerializableAsDomValue(domValue)) {
5970
+ return domValue;
5971
+ }
5972
+ naviJsRegistry.set(id, domValue);
5973
+ return `window.__navi_js('${id}')`;
5974
+ };
5975
+
5976
+ window.__navi_js = (id) => naviJsRegistry.get(id);
5977
+ const isSerializableAsDomValue = (value) => {
5978
+ if (value === null || value === undefined) {
5979
+ return true;
5980
+ }
5981
+ const type = typeof value;
5982
+ return type === "string" || type === "number" || type === "boolean";
5983
+ };
5984
+
5985
+ const onUIStateControllerCreated = (uiStateController) => {
5986
+ const { id, name, controlType } = uiStateController;
5987
+ if (id) {
5988
+ {
5989
+ const existing = controllersById.get(id);
5990
+ if (
5991
+ existing &&
5992
+ existing !== uiStateController &&
5993
+ !controllersWarned.has(uiStateController)
5994
+ ) {
5995
+ controllersWarned.add(uiStateController);
5996
+ console.warn(
5997
+ `[navi] Two controls share the same id "${id}" ("${existing.controlType}" and "${controlType}"). ` +
5998
+ `The id resolves to whichever of them rendered last, so the other one is unreachable through it (undefined uiState, dead picker buttons). ` +
5999
+ `Common causes: several controls inside a single <Field> (the Field provides one id via context and every control consumes it — put each control in its own <Field>, or give one an explicit id), ` +
6000
+ `or the same content mounted twice (e.g. two popups sharing an id prefix while both hold their content).`,
6001
+ );
6002
+ }
6003
+ }
6004
+ controllersById.set(id, uiStateController);
6005
+ }
6006
+ const proxyFor = uiStateController.props["navi-control-proxy-for"];
6007
+ if (proxyFor) {
6008
+ let proxySet = proxyControllersByRealInputId.get(proxyFor);
6009
+ if (!proxySet) {
6010
+ proxySet = new Set();
6011
+ proxyControllersByRealInputId.set(proxyFor, proxySet);
6012
+ }
6013
+ proxySet.add(uiStateController);
6014
+ }
6015
+ if (
6016
+ controlType === "input" &&
6017
+ uiStateController.props.type === "radio" &&
6018
+ name
6019
+ ) {
6020
+ let set = radioControllersByName.get(name);
6021
+ if (!set) {
6022
+ set = new Set();
6023
+ radioControllersByName.set(name, set);
6024
+ }
6025
+ set.add(uiStateController);
6026
+ }
6027
+ };
6028
+ const onUIStateControllerDestroyed = (uiStateController) => {
6029
+ const { id, name, controlType } = uiStateController;
6030
+ if (id) {
6031
+ // Only the controller the id currently points at may take the entry away:
6032
+ // when two controls share an id, the one leaving would otherwise unregister
6033
+ // the one staying (see the warning in onUIStateControllerCreated), and the
6034
+ // same holds while a control is being replaced by its successor, which
6035
+ // registers during its render, before this cleanup runs.
6036
+ if (controllersById.get(id) === uiStateController) {
6037
+ controllersById.delete(id);
6038
+ naviJsRegistry.delete(id);
6039
+ }
6040
+ }
6041
+ const proxyFor = uiStateController.props["navi-control-proxy-for"];
6042
+ if (proxyFor) {
6043
+ const proxySet = proxyControllersByRealInputId.get(proxyFor);
6044
+ if (proxySet) {
6045
+ proxySet.delete(uiStateController);
6046
+ if (proxySet.size === 0) {
6047
+ proxyControllersByRealInputId.delete(proxyFor);
6048
+ }
6049
+ }
6050
+ }
6051
+ if (
6052
+ controlType === "input" &&
6053
+ uiStateController.controlHostProps.type === "radio" &&
6054
+ name
6055
+ ) {
6056
+ const set = radioControllersByName.get(name);
6057
+ if (set) {
6058
+ set.delete(uiStateController);
6059
+ if (set.size === 0) {
6060
+ radioControllersByName.delete(name);
6061
+ }
6062
+ }
6063
+ }
6064
+ };
6065
+
6066
+ /**
6067
+ * Controller-based equivalent of findControlProxyTarget.
6068
+ * Given a proxy controller, returns the real control's controller.
6069
+ * Finds the target by walking the parent controller's children — no DOM queries.
6070
+ * Returns `null` when the controller is not a proxy or the target is not found.
6071
+ */
6072
+ const findControlProxyTargetController = (controller) => {
6073
+ const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
6074
+ if (!proxyFor) {
6075
+ return null;
6076
+ }
6077
+ return getUIStateControllerById(proxyFor) ?? null;
6078
+ };
6079
+
6080
+ // Reverse-lookup map: real-input id → the proxy controllers that reference it
6081
+ // via `navi-control-proxy-for`. A single control can be represented by several
6082
+ // proxies (an "enable"/"disable" button pair for one radio, for instance), so
6083
+ // each id holds a set. Maintained on create/destroy so lookup is O(1).
6084
+ const proxyControllersByRealInputId = new Map();
6085
+ const findProxyControllers = (realInputId) => {
6086
+ if (!realInputId) {
6087
+ return null;
6088
+ }
6089
+ return proxyControllersByRealInputId.get(realInputId) ?? null;
6090
+ };
6091
+
4900
6092
  /**
4901
6093
  * The attributes constraints read, filled by each constraint module as it
4902
6094
  * evaluates. A constraint declares the attribute it wants (`"data-no-emoji"`)
@@ -5549,6 +6741,113 @@ const isInertOnClick = (element) => {
5549
6741
  return true;
5550
6742
  };
5551
6743
 
6744
+ /**
6745
+ * DOM utilities for the proxy control pattern.
6746
+ *
6747
+ * Some components need a native `<input>` internally — for form submission,
6748
+ * constraint validation, or browser autofill — but the user may not want to
6749
+ * display that input at all. In those cases the input is hidden and a separate
6750
+ * visible element (the proxy) takes over the visual and interactive role.
6751
+ *
6752
+ * The typical use case is `SelectableList`: each list item acts as a styled
6753
+ * radio button, but an actual `<input type="radio">` lives hidden in the DOM
6754
+ * so form submission and validation work natively. When users DO want to
6755
+ * display the input they want full control over its appearance, so they render
6756
+ * their own element and link it to the real input via `navi-control-proxy-for`:
6757
+ *
6758
+ * ```html
6759
+ * <div>
6760
+ * <input id="color_red" type="radio" name="color" value="red" /> ← real control (hidden, drives form/validation)
6761
+ * <input type="radio" name="proxy" value="red" /> ← proxy (visible, delegates interactions to real input)
6762
+ * </div>
6763
+ * ```
6764
+ *
6765
+ * A press on a checkable proxy is dealt to the real control, and so is the
6766
+ * focus: state management, validation and form submission then all happen
6767
+ * where the value lives. A proxy that says what its press does — a button
6768
+ * carrying `command="--navi-check"` — answers the press itself instead.
6769
+ *
6770
+ * Note: an alternative design would be to require users to always instantiate
6771
+ * the input explicitly — e.g. `<Selectable.Input headless />` when they don't
6772
+ * want to display it. That would remove the need for the proxy mechanism
6773
+ * entirely. For now we keep the proxy pattern.
6774
+ */
6775
+
6776
+
6777
+ /**
6778
+ * Given a proxy element, returns the real control it represents.
6779
+ * Returns `null` when `el` is not a proxy.
6780
+ */
6781
+ const findControlProxyTarget = (el) => {
6782
+ const proxyFor = el.getAttribute("navi-control-proxy-for");
6783
+ if (!proxyFor) {
6784
+ return null;
6785
+ }
6786
+ return document.getElementById(proxyFor);
6787
+ };
6788
+
6789
+ // Shared: asked on every pseudo state change of every box, and almost always
6790
+ // empty.
6791
+ const NO_PROXIES = Object.freeze([]);
6792
+ /**
6793
+ * Given a real control element, returns every proxy that visually represents
6794
+ * it — a control can have more than one (an "enable"/"disable" button pair for
6795
+ * one radio, for instance).
6796
+ *
6797
+ * Answered from the controller registry rather than the document: every proxy
6798
+ * declares itself through the `navi-control-proxy-for` prop, so the registry
6799
+ * knows them all, while asking the document means walking it in full for each
6800
+ * of the (overwhelmingly many) controls that have no proxy at all.
6801
+ *
6802
+ * Returns an empty array when no proxy exists for `el`.
6803
+ */
6804
+ const findControlProxies = (el) => {
6805
+ if (!el.id) {
6806
+ return NO_PROXIES;
6807
+ }
6808
+ const proxyControllerSet = findProxyControllers(el.id);
6809
+ if (!proxyControllerSet) {
6810
+ return NO_PROXIES;
6811
+ }
6812
+ const proxyElements = [];
6813
+ for (const proxyController of proxyControllerSet) {
6814
+ const proxyElement = proxyController.ref.current;
6815
+ if (proxyElement) {
6816
+ proxyElements.push(proxyElement);
6817
+ }
6818
+ }
6819
+ return proxyElements;
6820
+ };
6821
+
6822
+ /**
6823
+ * Given a real control element, returns the proxy a gesture on the control
6824
+ * lands on — the one thing said about the control is drawn on.
6825
+ *
6826
+ * Use when you need a single visible stand-in for the real control — anchoring
6827
+ * a callout, for instance. Anything notifying proxies of a state change wants
6828
+ * `findControlProxies` instead, so a control represented by several of them
6829
+ * updates all of them.
6830
+ *
6831
+ * A proxy the pointer cannot reach is drawn for the control without standing
6832
+ * for it: the surface around it takes the presses for everything it holds, so
6833
+ * that surface is where a refusal about the control belongs — not a box inside
6834
+ * it the finger never has to find. A selectable list row whose whole area is
6835
+ * the click target is exactly that arrangement (see `selectableArea`), and it
6836
+ * is read from the arrangement itself rather than from a flag repeating it:
6837
+ * where a gesture can land IS pointer-events.
6838
+ *
6839
+ * Returns `null` when no proxy stands for `el`.
6840
+ */
6841
+ const findControlProxy = (el) => {
6842
+ for (const proxyElement of findControlProxies(el)) {
6843
+ if (getComputedStyle(proxyElement).pointerEvents === "none") {
6844
+ continue;
6845
+ }
6846
+ return proxyElement;
6847
+ }
6848
+ return null;
6849
+ };
6850
+
5552
6851
  /**
5553
6852
  * Typography reset shared by the surfaces a control opens: callout, popover,
5554
6853
  * dialog.
@@ -11823,7 +13122,8 @@ const useActionBoundToOneParam = (action, paramsSignal) => {
11823
13122
  }, []);
11824
13123
  return [boundAction, getValue(), setValue];
11825
13124
  };
11826
- // used by <details> to just call their action
13125
+ // The action bound to the signal, without reading it: the caller is not
13126
+ // re-rendered by the params (a control, a <details>).
11827
13127
  const useAction = (action, paramsSignal) => {
11828
13128
  return useBoundAction(action, paramsSignal);
11829
13129
  };
@@ -19955,6 +21255,1481 @@ const getDefaultDisplay = (tagName) => {
19955
21255
  return TAG_NAME_TO_DEFAULT_DISPLAY.get(normalizedTagName) || "inline";
19956
21256
  };
19957
21257
 
21258
+ const addInputEffect = (
21259
+ input,
21260
+ callback,
21261
+ { waitForChange = false, debounce = 0, debugInteraction = () => {} } = {},
21262
+ ) => {
21263
+ const getState =
21264
+ input.type === "checkbox" || input.type === "radio"
21265
+ ? () => input.checked
21266
+ : () => input.value;
21267
+
21268
+ if (waitForChange) {
21269
+ return listenInputStateChange(input, callback, { getState });
21270
+ }
21271
+
21272
+ const [teardown, addTeardown] = createPubSub();
21273
+ let currentState = getState();
21274
+ let timeout;
21275
+ let debounceTimeout;
21276
+ let onEvent;
21277
+ if (debounce) {
21278
+ onEvent = (e, { skipDebounce } = {}) => {
21279
+ clearTimeout(timeout);
21280
+ clearTimeout(debounceTimeout);
21281
+ const state = getState();
21282
+ if (state === currentState) {
21283
+ debugInteraction(
21284
+ e,
21285
+ `interaction ignored (state unchanged: "${state}")`,
21286
+ );
21287
+ return;
21288
+ }
21289
+
21290
+ if (skipDebounce) {
21291
+ debugInteraction(e, `skip debounce, callback called with "${state}"`);
21292
+ if (callback(e) !== false) {
21293
+ currentState = state;
21294
+ }
21295
+ } else {
21296
+ debugInteraction(
21297
+ e,
21298
+ `debounced ${debounce}ms, pending callback with "${state}"`,
21299
+ );
21300
+ debounceTimeout = setTimeout(() => {
21301
+ debugInteraction(
21302
+ e,
21303
+ `debounce elapsed, callback called with "${state}"`,
21304
+ );
21305
+ if (callback(e) !== false) {
21306
+ currentState = state;
21307
+ }
21308
+ }, debounce);
21309
+ }
21310
+ };
21311
+ // no need to wait for change events (blur, enter key)
21312
+ // we consider this as strong interactions requesting an immediate response
21313
+ const stop = listenInputStateChange(
21314
+ input,
21315
+ (e) => {
21316
+ onEvent(e, { skipDebounce: true });
21317
+ },
21318
+ { getState, changeOnEnter: true },
21319
+ );
21320
+ addTeardown(() => {
21321
+ stop();
21322
+ });
21323
+ } else {
21324
+ onEvent = (e) => {
21325
+ clearTimeout(timeout);
21326
+ const state = getState();
21327
+ if (state === currentState) {
21328
+ debugInteraction(
21329
+ e,
21330
+ `interaction ignored (state unchanged: "${state}")`,
21331
+ );
21332
+ return;
21333
+ }
21334
+ debugInteraction(e, `callback called with "${state}"`);
21335
+ if (callback(e) !== false) {
21336
+ currentState = state;
21337
+ }
21338
+ };
21339
+ // Autocomplete, programmatic changes, form restoration
21340
+ input.addEventListener("change", onEvent);
21341
+ addTeardown(() => {
21342
+ input.removeEventListener("change", onEvent);
21343
+ });
21344
+ }
21345
+
21346
+ const onAsyncEvent = (e, options) => {
21347
+ debugInteraction(e, `async event, scheduling callback`);
21348
+ timeout = setTimeout(() => {
21349
+ onEvent(e, options);
21350
+ }, 0);
21351
+ };
21352
+
21353
+ // Standard user input (typing)
21354
+ const onInput = (e) => {
21355
+ onEvent(e);
21356
+ };
21357
+ input.addEventListener("input", onInput);
21358
+ addTeardown(() => {
21359
+ input.removeEventListener("input", onInput);
21360
+ });
21361
+
21362
+ // Form reset - need to check the form
21363
+ const form = input.form;
21364
+ if (form) {
21365
+ // Form reset happens asynchronously, check value after reset completes
21366
+ form.addEventListener("reset", onAsyncEvent);
21367
+ addTeardown(() => {
21368
+ form.removeEventListener("reset", onAsyncEvent);
21369
+ });
21370
+ }
21371
+
21372
+ // Paste events (some browsers need special handling)
21373
+ input.addEventListener("paste", onEvent);
21374
+ addTeardown(() => {
21375
+ input.removeEventListener("paste", onEvent);
21376
+ });
21377
+
21378
+ input.addEventListener("navi_ui_state_change", (e) => {
21379
+ // radios are unchecked by an internal setUIState call when another radio is checked.
21380
+ // navi_ui_state_change is dispatched whenever setUIState changes state, so we
21381
+ // listen here to keep currentState in sync — otherwise input_effect thinks the
21382
+ // radio is still checked and ignores the next user click as "state unchanged".
21383
+ if (input.type === "radio") {
21384
+ currentState = e.detail.value;
21385
+ }
21386
+ const clearEvent = findEvent(
21387
+ e,
21388
+ (eInChain) =>
21389
+ eInChain.type === "navi_set_ui_state" && eInChain.detail.isClear,
21390
+ );
21391
+ const isClear = Boolean(clearEvent);
21392
+ if (isClear) {
21393
+ // "navi_clear" behaves like an async event
21394
+ // a bit like form reset because
21395
+ // our action will be updated async after the component re-renders
21396
+ // and we need to wait that to happen to properly call action with the right value
21397
+ debugInteraction(e, `navi_clear received, scheduling callback`);
21398
+ onAsyncEvent(e, { skipDebounce: true });
21399
+ }
21400
+ });
21401
+
21402
+ return teardown;
21403
+ };
21404
+
21405
+ const listenInputStateChange = (
21406
+ input,
21407
+ callback,
21408
+ { getState, changeOnEnter },
21409
+ ) => {
21410
+ const [teardown, addTeardown] = createPubSub();
21411
+
21412
+ let stateAtInteraction;
21413
+ const oninput = () => {
21414
+ stateAtInteraction = undefined;
21415
+ };
21416
+ const onkeydown = (e) => {
21417
+ if (e.key === "Enter") {
21418
+ /**
21419
+ * Browser trigger a "change" event right after the enter is pressed
21420
+ * if the input value has changed.
21421
+ * We need to prevent the next change event otherwise we would request action twice
21422
+ */
21423
+ stateAtInteraction = getState();
21424
+ if (changeOnEnter) {
21425
+ onchange(e);
21426
+ }
21427
+ }
21428
+ if (e.key === "Escape") {
21429
+ /**
21430
+ * Browser trigger a "change" event right after the escape is pressed
21431
+ * if the input value has changed.
21432
+ * We need to prevent the next change event otherwise we would request action when
21433
+ * we actually want to cancel
21434
+ */
21435
+ stateAtInteraction = getState();
21436
+ }
21437
+ };
21438
+ const onchange = (e) => {
21439
+ if (stateAtInteraction !== undefined && getState() === stateAtInteraction) {
21440
+ stateAtInteraction = undefined;
21441
+ return;
21442
+ }
21443
+ callback(e);
21444
+ };
21445
+ input.addEventListener("input", oninput);
21446
+ input.addEventListener("keydown", onkeydown);
21447
+ input.addEventListener("change", onchange);
21448
+ addTeardown(() => {
21449
+ input.removeEventListener("input", oninput);
21450
+ input.removeEventListener("keydown", onkeydown);
21451
+ input.removeEventListener("change", onchange);
21452
+ });
21453
+
21454
+ {
21455
+ // Handle programmatic value changes that don't trigger browser change events
21456
+ //
21457
+ // Problem: When input values are set programmatically (not by user typing),
21458
+ // browsers don't fire the 'change' event. However, our application logic
21459
+ // still needs to detect these changes.
21460
+ //
21461
+ // Example scenario:
21462
+ // 1. User starts editing (letter key pressed, value set programmatically)
21463
+ // 2. User doesn't type anything additional (this is the key part)
21464
+ // 3. User clicks outside to finish editing
21465
+ // 4. Without this code, no change event would fire despite the fact that the input value did change from its original state
21466
+ //
21467
+ // This distinction is crucial because:
21468
+ //
21469
+ // - If the user typed additional text after the initial programmatic value,
21470
+ // the browser would fire change events normally
21471
+ // - But when they don't type anything else, the browser considers it as "no user interaction"
21472
+ // even though the programmatic initial value represents a meaningful change
21473
+ //
21474
+ // We achieve this by checking if the input value has changed between focus and blur without any user interaction
21475
+ // if yes we fire the callback because input value did change
21476
+ let stateAtStart = getState();
21477
+ let interacted = false;
21478
+
21479
+ const onfocus = () => {
21480
+ interacted = false;
21481
+ stateAtStart = getState();
21482
+ };
21483
+ const oninput = (e) => {
21484
+ if (!e.isTrusted) {
21485
+ // non trusted "input" events will be ignored by the browser when deciding to fire "change" event
21486
+ // we ignore them too
21487
+ return;
21488
+ }
21489
+ interacted = true;
21490
+ };
21491
+ const onblur = (e) => {
21492
+ if (interacted) {
21493
+ return;
21494
+ }
21495
+ if (stateAtStart === getState()) {
21496
+ return;
21497
+ }
21498
+ callback(e);
21499
+ };
21500
+
21501
+ input.addEventListener("focus", onfocus);
21502
+ input.addEventListener("input", oninput);
21503
+ input.addEventListener("blur", onblur);
21504
+ addTeardown(() => {
21505
+ input.removeEventListener("focus", onfocus);
21506
+ input.removeEventListener("input", oninput);
21507
+ input.removeEventListener("blur", onblur);
21508
+ });
21509
+ }
21510
+
21511
+ return teardown;
21512
+ };
21513
+
21514
+ const requestPseudoStateCheck = (element, detail) => {
21515
+ dispatchInternalCustomEvent(
21516
+ element,
21517
+ "navi_pseudo_state_request_check",
21518
+ detail,
21519
+ );
21520
+ // When a control has visible proxies mirroring its state (e.g. selectable
21521
+ // radio with `navi-control-proxy-for`), re-check them too so they stay in
21522
+ // sync with the real control.
21523
+ for (const proxy of findControlProxies(element)) {
21524
+ dispatchInternalCustomEvent(
21525
+ proxy,
21526
+ "navi_pseudo_state_request_check",
21527
+ detail,
21528
+ );
21529
+ }
21530
+ };
21531
+ /**
21532
+ * Called back whenever `element`'s pseudo state changes.
21533
+ *
21534
+ * Kept beside the element rather than announced to the DOM: an element that
21535
+ * changes state has at most one or two interested parties, each known by name
21536
+ * (the box drawing it, the accent color reading its computed style), and a
21537
+ * CustomEvent costs its allocation and its capture/bubble walk whether anyone
21538
+ * listens or not — paid on every element, on every state change. A lookup that
21539
+ * finds nothing costs nothing.
21540
+ *
21541
+ * @param {Element} element
21542
+ * @param {(pseudoState: object, oldPseudoState: object) => void} callback
21543
+ * @returns {() => void} teardown
21544
+ */
21545
+ const subscribeToPseudoState = (element, callback) => {
21546
+ let subscriberSet = pseudoStateSubscriberSetWeakMap.get(element);
21547
+ if (!subscriberSet) {
21548
+ subscriberSet = new Set();
21549
+ pseudoStateSubscriberSetWeakMap.set(element, subscriberSet);
21550
+ }
21551
+ subscriberSet.add(callback);
21552
+ return () => {
21553
+ subscriberSet.delete(callback);
21554
+ };
21555
+ };
21556
+ const pseudoStateSubscriberSetWeakMap = new WeakMap();
21557
+ const notifyPseudoStateSubscribers = (element, value, oldValue) => {
21558
+ const subscriberSet = pseudoStateSubscriberSetWeakMap.get(element);
21559
+ if (!subscriberSet) {
21560
+ return;
21561
+ }
21562
+ for (const subscriber of subscriberSet) {
21563
+ subscriber(value, oldValue);
21564
+ }
21565
+ };
21566
+
21567
+ const PSEUDO_CLASSES = {};
21568
+ Object.assign(PSEUDO_CLASSES, {
21569
+ ":valid": {
21570
+ attribute: "data-valid",
21571
+ test: (el) => el.matches(":valid"),
21572
+ },
21573
+ ":invalid": {
21574
+ attribute: "data-invalid",
21575
+ test: (el) => el.matches(":invalid"),
21576
+ },
21577
+ ":visited": {
21578
+ attribute: "data-visited",
21579
+ },
21580
+ // Written by whoever knows the current url — a Link from its href, a Button
21581
+ // from its route — so it lives here rather than with one of them.
21582
+ ":-navi-href-current": {
21583
+ attribute: "data-href-current",
21584
+ },
21585
+ });
21586
+ const definePseudoClass = (pseudoClass, definition) => {
21587
+ PSEUDO_CLASSES[pseudoClass] = definition;
21588
+ };
21589
+
21590
+ // On touch devices (hover: none), browsers synthesize mouseenter/mouseleave
21591
+ // from touch events but never fire mouseleave when the finger lifts, leaving
21592
+ // el.matches(":hover") stuck at true. This causes hover styles (e.g. input
21593
+ // background highlight) to remain visible long after the user has stopped
21594
+ // touching the element. Checking (hover: hover) lets us skip hover tracking
21595
+ // entirely on touch-only devices where persistent hover makes no sense.
21596
+ const hoverSupported = window.matchMedia("(hover: hover)").matches;
21597
+ definePseudoClass(":hover", {
21598
+ attribute: "data-hover",
21599
+ setup: (el, callback) => {
21600
+ if (!hoverSupported) {
21601
+ return () => {};
21602
+ }
21603
+ const recheckProxy = (e) => {
21604
+ for (const proxy of findControlProxies(el)) {
21605
+ requestPseudoStateCheck(proxy, { event: e });
21606
+ }
21607
+ };
21608
+ const recheckProxyTarget = (e) => {
21609
+ const proxyTarget = findControlProxyTarget(el);
21610
+ if (proxyTarget) {
21611
+ requestPseudoStateCheck(proxyTarget, { event: e });
21612
+ }
21613
+ };
21614
+ let onmouseenter = (e) => {
21615
+ callback();
21616
+ recheckProxy(e);
21617
+ recheckProxyTarget(e);
21618
+ };
21619
+ let onmouseleave = (e) => {
21620
+ callback();
21621
+ recheckProxy(e);
21622
+ recheckProxyTarget(e);
21623
+ };
21624
+
21625
+ if (el.tagName === "LABEL") {
21626
+ // input.matches(":hover") is true when hovering the label
21627
+ // so when label is hovered/not hovered we need to recheck the input too
21628
+ const recheckInput = (e) => {
21629
+ if (el.htmlFor) {
21630
+ const input = document.getElementById(el.htmlFor);
21631
+ if (!input) {
21632
+ // cannot find the input for this label in the DOM
21633
+ return;
21634
+ }
21635
+ requestPseudoStateCheck(input, { event: e });
21636
+ return;
21637
+ }
21638
+ const input = el.querySelector("input, textarea, select");
21639
+ if (!input) {
21640
+ // label does not contain an input
21641
+ return;
21642
+ }
21643
+ requestPseudoStateCheck(input, { event: e });
21644
+ };
21645
+ const _onmouseenter = onmouseenter;
21646
+ onmouseenter = (e) => {
21647
+ recheckInput(e);
21648
+ _onmouseenter(e);
21649
+ };
21650
+ const _onmouseleave = onmouseleave;
21651
+ onmouseleave = (e) => {
21652
+ recheckInput(e);
21653
+ _onmouseleave(e);
21654
+ };
21655
+ }
21656
+
21657
+ el.addEventListener("mouseenter", onmouseenter);
21658
+ el.addEventListener("mouseleave", onmouseleave);
21659
+ return () => {
21660
+ el.removeEventListener("mouseenter", onmouseenter);
21661
+ el.removeEventListener("mouseleave", onmouseleave);
21662
+ };
21663
+ },
21664
+ test: (el) => {
21665
+ if (!hoverSupported) {
21666
+ return false;
21667
+ }
21668
+ if (el.matches(":hover")) {
21669
+ return true;
21670
+ }
21671
+ for (const proxy of findControlProxies(el)) {
21672
+ if (proxy.matches(":hover")) {
21673
+ return true;
21674
+ }
21675
+ }
21676
+ return false;
21677
+ },
21678
+ });
21679
+ definePseudoClass(":disabled", {
21680
+ attribute: "data-disabled",
21681
+ add: (el) => {
21682
+ if (
21683
+ el.tagName === "BUTTON" ||
21684
+ el.tagName === "INPUT" ||
21685
+ el.tagName === "SELECT" ||
21686
+ el.tagName === "TEXTAREA"
21687
+ ) {
21688
+ el.disabled = true;
21689
+ }
21690
+ },
21691
+ remove: (el) => {
21692
+ if (
21693
+ el.tagName === "BUTTON" ||
21694
+ el.tagName === "INPUT" ||
21695
+ el.tagName === "SELECT" ||
21696
+ el.tagName === "TEXTAREA"
21697
+ ) {
21698
+ el.disabled = false;
21699
+ }
21700
+ },
21701
+ });
21702
+ definePseudoClass(":read-only", {
21703
+ attribute: "data-readonly",
21704
+ add: (el) => {
21705
+ if (
21706
+ el.tagName === "INPUT" ||
21707
+ el.tagName === "SELECT" ||
21708
+ el.tagName === "TEXTAREA"
21709
+ ) {
21710
+ if (el.type === "checkbox" || el.type === "radio") {
21711
+ // there is no readOnly for checkboxes/radios
21712
+ return;
21713
+ }
21714
+ // el.readOnly = true;
21715
+ }
21716
+ },
21717
+ remove: (el) => {
21718
+ if (
21719
+ el.tagName === "INPUT" ||
21720
+ el.tagName === "SELECT" ||
21721
+ el.tagName === "TEXTAREA"
21722
+ ) {
21723
+ if (el.type === "checkbox" || el.type === "radio") {
21724
+ // there is no readOnly for checkboxes/radios
21725
+ return;
21726
+ }
21727
+ // el.readOnly = false;
21728
+ }
21729
+ },
21730
+ });
21731
+ definePseudoClass(":checked", {
21732
+ attribute: "data-checked",
21733
+ setup: (el, callback) => {
21734
+ if (el.type === "checkbox") {
21735
+ // Listen to user interactions
21736
+ el.addEventListener("input", callback);
21737
+ // Intercept programmatic changes to .checked property
21738
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
21739
+ HTMLInputElement.prototype,
21740
+ "checked",
21741
+ );
21742
+ Object.defineProperty(el, "checked", {
21743
+ get: originalDescriptor.get,
21744
+ set(value) {
21745
+ originalDescriptor.set.call(this, value);
21746
+ callback();
21747
+ },
21748
+ configurable: true,
21749
+ });
21750
+ return () => {
21751
+ // Restore original property descriptor
21752
+ Object.defineProperty(el, "checked", originalDescriptor);
21753
+ el.removeEventListener("input", callback);
21754
+ };
21755
+ }
21756
+ if (el.type === "radio") {
21757
+ // Listen to changes on the radio
21758
+ el.addEventListener("input", callback);
21759
+ // Intercept programmatic changes to .checked property
21760
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
21761
+ HTMLInputElement.prototype,
21762
+ "checked",
21763
+ );
21764
+ Object.defineProperty(el, "checked", {
21765
+ get: originalDescriptor.get,
21766
+ set(value) {
21767
+ originalDescriptor.set.call(this, value);
21768
+ callback();
21769
+ },
21770
+ configurable: true,
21771
+ });
21772
+ return () => {
21773
+ el.removeEventListener("input", callback);
21774
+ // Restore original property descriptor
21775
+ Object.defineProperty(el, "checked", originalDescriptor);
21776
+ };
21777
+ }
21778
+ if (el.tagName === "INPUT") {
21779
+ el.addEventListener("input", callback);
21780
+ return () => {
21781
+ el.removeEventListener("input", callback);
21782
+ };
21783
+ }
21784
+ return () => {};
21785
+ },
21786
+ test: (el) => el.matches(":checked"),
21787
+ });
21788
+ definePseudoClass(":active", {
21789
+ attribute: "data-active",
21790
+ setup: (el, callback) => {
21791
+ // I'ts recommended to use :-navi-pressed over :active for interactive elements.
21792
+ const onPointerDown = () => {
21793
+ const onRelease = () => {
21794
+ document.removeEventListener("pointercancel", onRelease, true);
21795
+ document.removeEventListener("pointerup", onRelease, true);
21796
+ callback();
21797
+ };
21798
+ document.addEventListener("pointercancel", onRelease, true);
21799
+ document.addEventListener("pointerup", onRelease, true);
21800
+ callback();
21801
+ };
21802
+ el.addEventListener("pointerdown", onPointerDown);
21803
+ return () => {
21804
+ el.removeEventListener("pointerdown", onPointerDown);
21805
+ };
21806
+ },
21807
+ test: (el) => el.matches(":active"),
21808
+ });
21809
+
21810
+ // The current input modality: true after a keyboard navigation key (arrow keys,
21811
+ // Escape, Enter, Ctrl, Alt, Shift, Space — Space ignored on editable fields),
21812
+ // false after a pointer interaction. Updated by the listeners in the
21813
+ // focus_classes block below. At module scope so isMatchingFocusVisible (used
21814
+ // here and in control_hooks.jsx) can read it.
21815
+ let keyboardNavigationUsed = false;
21816
+
21817
+ // HOW FOCUS-VISIBLE IS DECIDED (the details behind isMatchingFocusVisible)
21818
+ //
21819
+ // Two rules, depending on what `el` is:
21820
+ //
21821
+ // 1. An EDITABLE target (text-ish input, textarea, contenteditable — anything
21822
+ // whose whole point is keyboard input, see isEditableTarget) shows its ring
21823
+ // whenever it holds the focus, however the focus arrived — mouse,
21824
+ // programmatic, even a focus({ focusVisible: false }). Being focused, for
21825
+ // such a field, means being about to type, and that is what the ring
21826
+ // announces. Its check is on :focus rather than :focus-visible on purpose:
21827
+ // the native :focus-visible obeys the focusVisible option, which callers
21828
+ // set from the modality without knowing what they are focusing.
21829
+ //
21830
+ // 2. Anything else needs BOTH the native :focus-visible match AND the current
21831
+ // modality being keyboard (`keyboardNavigationUsed`).
21832
+ //
21833
+ // The native match alone cannot be trusted, because we routinely take the
21834
+ // pointer out of the browser's hands: a picker opens on mousedown and calls
21835
+ // preventDefault() so the browser does not move focus itself (see
21836
+ // picker_custom.jsx). The browser therefore never registers that a pointer
21837
+ // was what moved focus, and whatever focus-visible state the previous
21838
+ // keyboard interaction left behind stays true — even across the
21839
+ // programmatic focus({ focusVisible: false }) that follows. Close a picker
21840
+ // with Escape (ring on the trigger, rightly) and click it open again: the
21841
+ // popup's own focusable would come up ringed, from a mouse press.
21842
+ //
21843
+ // `keyboardNavigationUsed` has no such blind spot — it is set by navigation
21844
+ // keydowns and cleared by pointerdown, whatever anyone prevents afterwards
21845
+ // — so it is the authority for everything rule 1 does not cover.
21846
+ //
21847
+ // Ring INHERITANCE (a controlled element ringing because its aria-controls
21848
+ // controller is focused) stays gated on the keyboard modality even for an
21849
+ // editable controller — see hasIndirectFocus: propagating a ring promises
21850
+ // keyboard shortcuts will drive the controlled element, a promise that only
21851
+ // holds once a physical keyboard has actually been used.
21852
+ /**
21853
+ * Whether `el` should currently show a focus ring — the enriched
21854
+ * :focus-visible behind every [data-focus-visible] navi renders.
21855
+ * Use this instead of a bare el.matches(":focus-visible") wherever
21856
+ * focus-visible is evaluated; the comment above details the rules.
21857
+ * @param {Element} el
21858
+ * @returns {boolean}
21859
+ */
21860
+ const isMatchingFocusVisible = (el) => {
21861
+ if (isEditableTarget(el)) {
21862
+ return el.matches(":focus");
21863
+ }
21864
+ if (!el.matches(":focus-visible")) {
21865
+ return false;
21866
+ }
21867
+ if (!keyboardNavigationUsed) {
21868
+ return false;
21869
+ }
21870
+ return true;
21871
+ };
21872
+
21873
+ // Elements that invite keyboard input: focusing one — even with the mouse —
21874
+ // means the user is about to type, so they always warrant a visible focus.
21875
+ // Also used to ignore the Space key as a navigation key while typing, and by
21876
+ // programmatic focus (focus_transfer.js) to pass focusVisible: true so the
21877
+ // native :focus-visible agrees with the ring rule 1 above draws.
21878
+ const EDITABLE_INPUT_TYPE_SET = new Set([
21879
+ "text",
21880
+ "search",
21881
+ "url",
21882
+ "email",
21883
+ "password",
21884
+ "tel",
21885
+ "number",
21886
+ "date",
21887
+ "time",
21888
+ "datetime-local",
21889
+ "month",
21890
+ "week",
21891
+ ]);
21892
+ const isEditableTarget = (target) => {
21893
+ if (!target) {
21894
+ return false;
21895
+ }
21896
+ const tag = target.tagName;
21897
+ if (tag === "TEXTAREA") {
21898
+ return !target.readOnly;
21899
+ }
21900
+ if (tag === "INPUT") {
21901
+ if (!target.type || EDITABLE_INPUT_TYPE_SET.has(target.type)) {
21902
+ return !target.readOnly;
21903
+ }
21904
+ }
21905
+ if (target.isContentEditable) {
21906
+ return true;
21907
+ }
21908
+ return false;
21909
+ };
21910
+
21911
+ // The current modality, for code deciding whether something it is about to
21912
+ // focus should show a ring — a slide arriving, a popup opening. The question is
21913
+ // "was the user on the keyboard when this was asked for", which is what this
21914
+ // flag says; the element that happens to hold the focus right now says nothing
21915
+ // about it (it may have been focused programmatically, ring or no ring, by the
21916
+ // travel before this one).
21917
+ const isKeyboardModality = () => keyboardNavigationUsed;
21918
+
21919
+ {
21920
+ // We implement :focus and :focus-visible with enriched semantics:
21921
+ // an element is considered focused not only when it natively has focus, but also
21922
+ // when a "focus proxy" element has focus (e.g. a read-only range input delegates
21923
+ // focus to a sibling span) or when a controlling element has focus (e.g. a combobox
21924
+ // input with aria-controls pointing to a listbox — the listbox should appear focused
21925
+ // while the input is focused).
21926
+ //
21927
+ // We intentionally reuse the native :focus / :focus-visible names rather than
21928
+ // introducing new navi-specific pseudo-classes (e.g. :-navi-focus). This is a
21929
+ // deliberate exception: all existing CSS and code written as [data-focus] or
21930
+ // [data-focus-visible] automatically benefits from the enriched behavior without
21931
+ // any changes. A separate navi-specific class would require updating every
21932
+ // component.
21933
+ //
21934
+ // When a controller element (e.g. combobox input) gains or loses focus,
21935
+ // notify the elements it controls via aria-controls so they re-check their
21936
+ // focus state. `requestPseudoStateCheck` also re-checks the controlled
21937
+ // element's proxy (if any), so the visible proxy mirrors the hidden real
21938
+ // input's inherited focus.
21939
+ const notifyAriaControlled = (el, e) => {
21940
+ const controlledIds = el.getAttribute("aria-controls");
21941
+ if (!controlledIds) {
21942
+ return;
21943
+ }
21944
+ for (const id of controlledIds.split(" ")) {
21945
+ const controlled = document.getElementById(id);
21946
+ if (controlled) {
21947
+ requestPseudoStateCheck(controlled, { event: e });
21948
+ }
21949
+ }
21950
+ };
21951
+ // Tracks whether the user has pressed a keyboard navigation key (arrow keys,
21952
+ // Escape, Enter, Ctrl, Alt, Shift, Space) since the last pointer interaction.
21953
+ // Space is ignored when the target is an editable field.
21954
+ // This flag is used to gate focus-visible inheritance via aria-controls:
21955
+ // on mobile (or when the user hasn't used keyboard nav yet) an input that
21956
+ // controls a radio should not cause the radio to show a focus ring.
21957
+ // (Declared at module scope — see keyboardNavigationUsed above.)
21958
+ const NAVIGATION_KEY_SET = new Set([
21959
+ "ArrowUp",
21960
+ "ArrowDown",
21961
+ "ArrowLeft",
21962
+ "ArrowRight",
21963
+ "Escape",
21964
+ "Enter",
21965
+ "Control",
21966
+ "Alt",
21967
+ "Shift",
21968
+ " ",
21969
+ "Tab",
21970
+ ]);
21971
+ document.addEventListener(
21972
+ "keydown",
21973
+ (e) => {
21974
+ if (!NAVIGATION_KEY_SET.has(e.key)) {
21975
+ return;
21976
+ }
21977
+ if (e.key === " " && isEditableTarget(e.target)) {
21978
+ return;
21979
+ }
21980
+ keyboardNavigationUsed = true;
21981
+ },
21982
+ { capture: true },
21983
+ );
21984
+ document.addEventListener(
21985
+ "pointerdown",
21986
+ () => {
21987
+ keyboardNavigationUsed = false;
21988
+ },
21989
+ { capture: true },
21990
+ );
21991
+
21992
+ // A keystroke flips keyboardNavigationUsed, which can turn :focus-visible on —
21993
+ // but only for the element that holds focus, directly or through aria-controls
21994
+ // / a proxy. Pressing a key cannot reveal a focus ring on an unfocused element.
21995
+ // So a single shared handler re-checks just that focus chain (the active
21996
+ // element, what it controls, and its proxy — the last two via
21997
+ // requestPseudoStateCheck / notifyAriaControlled). This runs in the bubble
21998
+ // phase, after the capture-phase listener above has updated the flag.
21999
+ //
22000
+ // The alternative — each registered :focus-visible element adding its own
22001
+ // document keydown listener that re-tests itself — makes one keypress cost
22002
+ // O(number-of-boxes) full-document [aria-controls] / proxy queries, since every
22003
+ // unfocused element falls through matches(":focus-visible") into hasIndirectFocus.
22004
+ const recheckFocusChainOnKey = (e) => {
22005
+ const active = document.activeElement;
22006
+ if (!active || active === document.body) {
22007
+ return;
22008
+ }
22009
+ requestPseudoStateCheck(active, { event: e });
22010
+ notifyAriaControlled(active, e);
22011
+ };
22012
+ document.addEventListener("keydown", recheckFocusChainOnKey);
22013
+ document.addEventListener("keyup", recheckFocusChainOnKey);
22014
+
22015
+ // Returns true when el holds focus indirectly — either because a controlling
22016
+ // element (aria-controls) has focus, or because el is a proxy whose target
22017
+ // is itself controlled by a focused element.
22018
+ const hasIndirectFocus = (el, { requireFocusVisible = false } = {}) => {
22019
+ // No ring inheritance without a keyboard: an editable target draws its own
22020
+ // ring on any focus (see isMatchingFocusVisible), but propagating that ring
22021
+ // to a controlled element (aria-controls) is a promise that keyboard
22022
+ // shortcuts will drive it — a promise that holds only once a physical
22023
+ // keyboard has actually been used. On touch devices the flag stays false
22024
+ // and a focused search input keeps its ring to itself.
22025
+ if (requireFocusVisible && !keyboardNavigationUsed) {
22026
+ return false;
22027
+ }
22028
+ // A controller/proxy counts as focused for inheritance via the same rule
22029
+ // used everywhere: :focus for plain inheritance, isMatchingFocusVisible for
22030
+ // the focus-visible variant (so a mouse-focused controller doesn't propagate
22031
+ // a ring).
22032
+ const isFocusedTarget = (target) =>
22033
+ requireFocusVisible
22034
+ ? isMatchingFocusVisible(target)
22035
+ : target.matches(":focus");
22036
+ // Both branches of isFocusedTarget rest on :focus / :focus-visible, and only
22037
+ // one element in the document can match those: document.activeElement. So
22038
+ // the single controller worth testing is known upfront — asking the document
22039
+ // for every [aria-controls] would collect candidates that cannot qualify,
22040
+ // once per element and again on every re-check, on a document each new
22041
+ // element makes bigger.
22042
+ const isControlledBy = (target) => {
22043
+ const id = target.id;
22044
+ if (!id) {
22045
+ return false;
22046
+ }
22047
+ const activeElement = document.activeElement;
22048
+ if (!activeElement || activeElement === document.body) {
22049
+ return false;
22050
+ }
22051
+ if (!activeElement.matches(`[aria-controls~="${id}"]`)) {
22052
+ return false;
22053
+ }
22054
+ // A controller inside the element it controls means focus is already
22055
+ // native (:focus-within) — nothing to inherit.
22056
+ if (target.contains(activeElement)) {
22057
+ return false;
22058
+ }
22059
+ return isFocusedTarget(activeElement);
22060
+ };
22061
+ if (isControlledBy(el)) {
22062
+ return true;
22063
+ }
22064
+ const proxyTarget = findControlProxyTarget(el);
22065
+ if (proxyTarget) {
22066
+ if (isFocusedTarget(proxyTarget)) {
22067
+ return true;
22068
+ }
22069
+ if (isControlledBy(proxyTarget)) {
22070
+ return true;
22071
+ }
22072
+ }
22073
+ return false;
22074
+ };
22075
+
22076
+ // One registration per element for everything focus-related, shared by
22077
+ // :focus, :focus-visible and :focus-within: they all react to the same
22078
+ // focusin/focusout, and they all re-check through the same callback (see
22079
+ // initPseudoStyles), which the Set turns into one call.
22080
+ const focusTrackingWeakMap = new WeakMap();
22081
+ const trackFocus = (el, callback) => {
22082
+ let tracking = focusTrackingWeakMap.get(el);
22083
+ if (!tracking) {
22084
+ const callbackSet = new Set();
22085
+ const onFocusChange = (e) => {
22086
+ for (const trackedCallback of callbackSet) {
22087
+ trackedCallback();
22088
+ }
22089
+ notifyAriaControlled(el, e);
22090
+ };
22091
+ el.addEventListener("focusin", onFocusChange);
22092
+ el.addEventListener("focusout", onFocusChange);
22093
+ tracking = {
22094
+ callbackSet,
22095
+ teardown: () => {
22096
+ el.removeEventListener("focusin", onFocusChange);
22097
+ el.removeEventListener("focusout", onFocusChange);
22098
+ focusTrackingWeakMap.delete(el);
22099
+ },
22100
+ };
22101
+ focusTrackingWeakMap.set(el, tracking);
22102
+ observeAriaControls();
22103
+ }
22104
+ tracking.callbackSet.add(callback);
22105
+ return () => {
22106
+ tracking.callbackSet.delete(callback);
22107
+ if (tracking.callbackSet.size === 0) {
22108
+ tracking.teardown();
22109
+ }
22110
+ };
22111
+ };
22112
+ // When aria-controls changes on a focused element, what it used to control
22113
+ // and what it controls now both re-check their inherited focus. One observer
22114
+ // on the document rather than one per tracked element: the attribute changes
22115
+ // rarely, on few elements, while thousands are tracked — and it can be set
22116
+ // after the element was, so "has it at setup" would miss it.
22117
+ let ariaControlsObserver = null;
22118
+ const observeAriaControls = () => {
22119
+ if (ariaControlsObserver) {
22120
+ return;
22121
+ }
22122
+ ariaControlsObserver = new MutationObserver((mutations) => {
22123
+ for (const mutation of mutations) {
22124
+ const el = mutation.target;
22125
+ if (!focusTrackingWeakMap.has(el)) {
22126
+ continue;
22127
+ }
22128
+ if (!el.matches(":focus-within")) {
22129
+ continue;
22130
+ }
22131
+ const oldIds = (mutation.oldValue || "").split(" ").filter(Boolean);
22132
+ for (const id of oldIds) {
22133
+ const controlled = document.getElementById(id);
22134
+ if (controlled) {
22135
+ requestPseudoStateCheck(controlled, {});
22136
+ }
22137
+ }
22138
+ notifyAriaControlled(el, {});
22139
+ }
22140
+ });
22141
+ ariaControlsObserver.observe(document.documentElement, {
22142
+ subtree: true,
22143
+ attributes: true,
22144
+ attributeFilter: ["aria-controls"],
22145
+ attributeOldValue: true,
22146
+ });
22147
+ };
22148
+
22149
+ definePseudoClass(":focus", {
22150
+ attribute: "data-focus",
22151
+ setup: trackFocus,
22152
+ test: (el) => {
22153
+ if (el.matches(":focus")) {
22154
+ return true;
22155
+ }
22156
+ if (hasIndirectFocus(el)) {
22157
+ return true;
22158
+ }
22159
+ return false;
22160
+ },
22161
+ });
22162
+ definePseudoClass(":focus-visible", {
22163
+ attribute: "data-focus-visible",
22164
+ // No per-element keydown/keyup listener: the shared recheckFocusChainOnKey
22165
+ // handler re-checks the focused element (the only one a keystroke can turn
22166
+ // focus-visible) so a keypress stays O(1), not O(number-of-boxes).
22167
+ setup: trackFocus,
22168
+ test: (el) => {
22169
+ if (isMatchingFocusVisible(el)) {
22170
+ return true;
22171
+ }
22172
+ if (hasIndirectFocus(el, { requireFocusVisible: true })) {
22173
+ return true;
22174
+ }
22175
+ return false;
22176
+ },
22177
+ });
22178
+ definePseudoClass(":focus-within", {
22179
+ attribute: "data-focus-within",
22180
+ setup: trackFocus,
22181
+ test: (el) => {
22182
+ if (el.matches(":focus-within")) {
22183
+ return true;
22184
+ }
22185
+ if (hasIndirectFocus(el)) {
22186
+ return true;
22187
+ }
22188
+ if (el.contains(document.activeElement)) {
22189
+ // for some reason :focus-within sometimes is false while focus is within...
22190
+ // (popover with chrome for some reason)
22191
+ return true;
22192
+ }
22193
+ return false;
22194
+ },
22195
+ });
22196
+ }
22197
+
22198
+ Object.assign(PSEUDO_CLASSES, {
22199
+ ":-navi-pointed": {
22200
+ attribute: "data-pointed",
22201
+ },
22202
+ ":-navi-pointed-by-mouse": {
22203
+ attribute: "data-pointed-by-mouse",
22204
+ },
22205
+ ":-navi-pointed-by-keyboard": {
22206
+ attribute: "data-pointed-by-keyboard",
22207
+ },
22208
+ ":-navi-pointed-by-proxy": {
22209
+ attribute: "data-pointed-by-proxy",
22210
+ },
22211
+ ":-navi-selected": {
22212
+ attribute: "data-selected",
22213
+ },
22214
+ ":-navi-loading": {
22215
+ attribute: "data-loading",
22216
+ },
22217
+ ":-navi-status-info": {
22218
+ attribute: "data-status-info",
22219
+ },
22220
+ ":-navi-status-success": {
22221
+ attribute: "data-status-success",
22222
+ },
22223
+ ":-navi-status-warning": {
22224
+ attribute: "data-status-warning",
22225
+ },
22226
+ ":-navi-status-error": {
22227
+ attribute: "data-status-error",
22228
+ },
22229
+ ":-navi-expanded": {
22230
+ attribute: "data-expanded",
22231
+ },
22232
+ ":-navi-void": {
22233
+ attribute: "data-void",
22234
+ },
22235
+ "::highlight": {},
22236
+ });
22237
+ definePseudoClass(":-navi-has-value", {
22238
+ attribute: "data-has-value",
22239
+ setup: (el, callback) => {
22240
+ const controlHost = findControlHost(el) || el;
22241
+ return addInputEffect(controlHost, callback);
22242
+ },
22243
+ test: (el) => {
22244
+ if (isControlHost(el)) {
22245
+ const uiState = getUIStateFromElement(el);
22246
+ if (uiState === undefined || uiState === "") {
22247
+ return false;
22248
+ }
22249
+ return true;
22250
+ }
22251
+ if (el.value === "") {
22252
+ return false;
22253
+ }
22254
+ return true;
22255
+ },
22256
+ });
22257
+ {
22258
+ const pressedElements = new WeakSet();
22259
+ definePseudoClass(":-navi-pressed", {
22260
+ attribute: "data-pressed",
22261
+ setup: (el, callback) => {
22262
+ // Prefer :-navi-pressed over :active for interactive elements because:
22263
+ // - :active only tracks the primary (left) button; right-click and touch
22264
+ // long-press do not trigger :active reliably across browsers.
22265
+ // - :-navi-pressed explicitly ignores non-primary buttons (e.g. right-click)
22266
+ // and correctly clears pressed state when a context menu opens on long-press,
22267
+ // which would otherwise leave the element stuck in a pressed appearance.
22268
+
22269
+ // Note: it might be tempting to use el.setPointerCapture() here so that pointerup
22270
+ // always fires on el regardless of where the pointer is released. However,
22271
+ // pointer capture routes all subsequent pointer events to the capturing element,
22272
+ // which means any other element in the tree that expects to receive pointerup,
22273
+ // mouseup, click, etc. after a pointerdown will silently not get them.
22274
+ // For example a <label> that reacts to mousedown + click, or a third-party
22275
+ // library that attaches its own listeners, would break because an ancestor
22276
+ // grabbed the pointer out from under them.
22277
+ // To avoid forcing every such element to declare an opt-out attribute
22278
+ // (e.g. navi-own-pointer-capture) we simply listen on document instead,
22279
+ // which is safe and does not interfere with anyone else's event flow.
22280
+ const onPointerDown = (e) => {
22281
+ if (e.button !== 0) {
22282
+ // only left pointer (mouse left click, touch, pen)
22283
+ return;
22284
+ }
22285
+ pressedElements.add(el);
22286
+ const onRelease = () => {
22287
+ pressedElements.delete(el);
22288
+ document.removeEventListener("pointercancel", onRelease, true);
22289
+ document.removeEventListener("pointerup", onRelease, true);
22290
+ document.removeEventListener("contextmenu", onContextMenu, true);
22291
+ callback();
22292
+ };
22293
+ const onContextMenu = (e) => {
22294
+ // On touch devices, a long-press triggers the context menu.
22295
+ // If the context menu is not prevented, it means it will open and the
22296
+ // pointer events (pointerup, lostpointercapture) won't fire normally,
22297
+ // leaving the element stuck in pressed state. We clear it manually.
22298
+ // e.button === -1 means the event was synthesized from a long-press (not a real mouse click).
22299
+ if (e.button === -1 && !e.defaultPrevented) {
22300
+ pressedElements.delete(el);
22301
+ document.removeEventListener("pointercancel", onRelease, true);
22302
+ document.removeEventListener("pointerup", onRelease, true);
22303
+ document.removeEventListener("contextmenu", onContextMenu, true);
22304
+ callback();
22305
+ }
22306
+ };
22307
+ document.addEventListener("pointercancel", onRelease, true);
22308
+ document.addEventListener("pointerup", onRelease, true);
22309
+ document.addEventListener("contextmenu", onContextMenu, true);
22310
+ callback();
22311
+ };
22312
+ el.addEventListener("pointerdown", onPointerDown);
22313
+ return () => {
22314
+ el.removeEventListener("pointerdown", onPointerDown);
22315
+ pressedElements.delete(el);
22316
+ };
22317
+ },
22318
+ test: (el) => pressedElements.has(el),
22319
+ });
22320
+ }
22321
+
22322
+ {
22323
+ definePseudoClass(":-navi-drag-grabbed", {
22324
+ attribute: "navi-drag-grabbed",
22325
+ setup: (el, callback) => {
22326
+ const onGrab = () => {
22327
+ callback();
22328
+ const onRelease = () => {
22329
+ el.removeEventListener("navi_drag_release", onRelease);
22330
+ callback();
22331
+ };
22332
+ el.addEventListener("navi_drag_release", onRelease);
22333
+ };
22334
+ el.addEventListener("navi_drag_grab", onGrab);
22335
+ return () => {
22336
+ el.removeEventListener("navi_drag_grab", onGrab);
22337
+ };
22338
+ },
22339
+ test: (el) => el.hasAttribute("data-drag-grabbed"),
22340
+ });
22341
+ definePseudoClass(":-navi-dragging", {
22342
+ attribute: "navi-dragging",
22343
+ setup: (el, callback) => {
22344
+ const onStart = () => {
22345
+ callback();
22346
+ const onRelease = () => {
22347
+ el.removeEventListener("navi_drag_release", onRelease);
22348
+ callback();
22349
+ };
22350
+ el.addEventListener("navi_drag_release", onRelease);
22351
+ };
22352
+ el.addEventListener("navi_drag_start", onStart);
22353
+ return () => {
22354
+ el.removeEventListener("navi_drag_start", onStart);
22355
+ };
22356
+ },
22357
+ test: (el) => el.hasAttribute("data-dragging"),
22358
+ });
22359
+ }
22360
+
22361
+ const EMPTY_STATE = {};
22362
+ const elementToImpactWeakMap = new WeakMap();
22363
+ const initPseudoStyles = (
22364
+ element,
22365
+ {
22366
+ pseudoClasses,
22367
+ pseudoState, // ":disabled", ":read-only", ":-navi-loading", etc...
22368
+ effect,
22369
+ elementToImpact = element,
22370
+ elementListeningPseudoState,
22371
+ },
22372
+ ) => {
22373
+ elementToImpactWeakMap.set(element, elementToImpact);
22374
+ if (elementListeningPseudoState === element) {
22375
+ console.warn(
22376
+ `elementListeningPseudoState should not be the same as element to avoid infinite loop`,
22377
+ );
22378
+ elementListeningPseudoState = null;
22379
+ }
22380
+
22381
+ const proxyTarget = findControlProxyTarget(element);
22382
+
22383
+ const onStateChange = (value, oldValue) => {
22384
+ effect?.(value, oldValue);
22385
+ if (elementListeningPseudoState) {
22386
+ notifyPseudoStateSubscribers(
22387
+ elementListeningPseudoState,
22388
+ value,
22389
+ oldValue,
22390
+ );
22391
+ }
22392
+ // When this element's state changes, notify any proxy element that mirrors it
22393
+ // so it can re-check and visually reflect the new state.
22394
+ for (const proxy of findControlProxies(element)) {
22395
+ requestPseudoStateCheck(proxy, {});
22396
+ }
22397
+ };
22398
+
22399
+ if (!pseudoClasses || pseudoClasses.length === 0) {
22400
+ onStateChange(EMPTY_STATE);
22401
+ return () => {};
22402
+ }
22403
+
22404
+ const [teardown, addTeardown] = createPubSub();
22405
+
22406
+ let state;
22407
+ const checkPseudoClasses = () => {
22408
+ let someChange = false;
22409
+ const currentState = {};
22410
+ for (const pseudoClass of pseudoClasses) {
22411
+ const pseudoClassDefinition = PSEUDO_CLASSES[pseudoClass];
22412
+ if (!pseudoClassDefinition) {
22413
+ console.warn(`Unknown pseudo class: ${pseudoClass}`);
22414
+ continue;
22415
+ }
22416
+ let currentValue;
22417
+ if (
22418
+ pseudoState &&
22419
+ Object.hasOwn(pseudoState, pseudoClass) &&
22420
+ pseudoState[pseudoClass] !== undefined
22421
+ ) {
22422
+ currentValue = pseudoState[pseudoClass];
22423
+ } else {
22424
+ const { test } = pseudoClassDefinition;
22425
+ if (test) {
22426
+ currentValue = test(element, pseudoState);
22427
+ }
22428
+ }
22429
+ // If this element is a proxy for another (navi-control-proxy-for="targetId"),
22430
+ // inherit the target's active pseudo-state when the element itself isn't in that state.
22431
+ // We check the target's elementToImpact (not the target itself) because the
22432
+ // data-* attribute may be set on a different element (e.g. pseudoStateSelector).
22433
+ if (!currentValue && proxyTarget) {
22434
+ const { attribute } = pseudoClassDefinition;
22435
+ if (attribute) {
22436
+ const targetElementToImpact =
22437
+ elementToImpactWeakMap.get(proxyTarget) || proxyTarget;
22438
+ if (targetElementToImpact.hasAttribute(attribute)) {
22439
+ currentValue = true;
22440
+ }
22441
+ }
22442
+ }
22443
+ currentState[pseudoClass] = currentValue;
22444
+ const oldValue = state ? state[pseudoClass] : undefined;
22445
+ if (oldValue !== currentValue || !state) {
22446
+ someChange = true;
22447
+ const { attribute, add, remove } = pseudoClassDefinition;
22448
+ if (currentValue) {
22449
+ if (attribute) {
22450
+ elementToImpact.setAttribute(attribute, "");
22451
+ }
22452
+ add?.(element);
22453
+ } else {
22454
+ if (attribute) {
22455
+ elementToImpact.removeAttribute(attribute);
22456
+ }
22457
+ remove?.(element);
22458
+ }
22459
+ }
22460
+ }
22461
+ if (!someChange) {
22462
+ return;
22463
+ }
22464
+ const oldState = state;
22465
+ state = currentState;
22466
+ onStateChange(state, oldState);
22467
+ };
22468
+
22469
+ addTeardown(
22470
+ subscribeToPseudoState(element, (pseudoState, oldPseudoState) => {
22471
+ state = pseudoState;
22472
+ onStateChange(state, oldPseudoState);
22473
+ }),
22474
+ );
22475
+ // One function for every way a re-check can be asked, so that setups
22476
+ // registering it side by side (the focus tracking, for one) hold the same
22477
+ // callback and call it once.
22478
+ const requestCheck = () => {
22479
+ checkPseudoClasses();
22480
+ };
22481
+ element.addEventListener("navi_pseudo_state_request_check", requestCheck);
22482
+ addTeardown(() => {
22483
+ element.removeEventListener(
22484
+ "navi_pseudo_state_request_check",
22485
+ requestCheck,
22486
+ );
22487
+ });
22488
+
22489
+ for (const pseudoClass of pseudoClasses) {
22490
+ const pseudoClassDefinition = PSEUDO_CLASSES[pseudoClass];
22491
+ if (!pseudoClassDefinition) {
22492
+ console.warn(`Unknown pseudo class: ${pseudoClass}`);
22493
+ continue;
22494
+ }
22495
+ const { setup } = pseudoClassDefinition;
22496
+ if (setup) {
22497
+ const cleanup = setup(element, requestCheck);
22498
+ addTeardown(cleanup);
22499
+ }
22500
+ }
22501
+ checkPseudoClasses();
22502
+ {
22503
+ // just in case + catch use forcing them in chrome devtools
22504
+ addTeardown(pollInDev(requestCheck));
22505
+ }
22506
+
22507
+ return teardown;
22508
+ };
22509
+
22510
+ const applyStyle = (
22511
+ element,
22512
+ style,
22513
+ pseudoState,
22514
+ pseudoNamedStyles,
22515
+ preventInitialTransition,
22516
+ ) => {
22517
+ if (!element) {
22518
+ return;
22519
+ }
22520
+ const styleToApply = getStyleToApply(style, pseudoState, pseudoNamedStyles);
22521
+ // The same object comes back for every state change of a box whose inline
22522
+ // style has no pseudo entry: nothing to write.
22523
+ if (appliedStyleWeakMap.get(element) === styleToApply) {
22524
+ return;
22525
+ }
22526
+ updateStyle(element, styleToApply, preventInitialTransition);
22527
+ };
22528
+
22529
+ const PSEUDO_STATE_DEFAULT = {};
22530
+ const PSEUDO_NAMED_STYLES_DEFAULT = {};
22531
+ const getStyleToApply = (styles, pseudoState, pseudoNamedStyles) => {
22532
+ if (
22533
+ !pseudoState ||
22534
+ pseudoState === PSEUDO_STATE_DEFAULT ||
22535
+ !pseudoNamedStyles ||
22536
+ pseudoNamedStyles === PSEUDO_NAMED_STYLES_DEFAULT
22537
+ ) {
22538
+ return styles;
22539
+ }
22540
+ let style = styles;
22541
+ for (const pseudoKey of Object.keys(pseudoNamedStyles)) {
22542
+ if (!hasEveryState(pseudoState, getPseudoKeyRequiredStates(pseudoKey))) {
22543
+ continue;
22544
+ }
22545
+ if (style === styles) {
22546
+ style = { ...styles };
22547
+ }
22548
+ // Both sides are already normalized for CSS by the box; only the
22549
+ // properties that compose (a press scale on top of a translate) go through
22550
+ // a merge, the rest is a plain override.
22551
+ const styleToAdd = pseudoNamedStyles[pseudoKey];
22552
+ for (const key of Object.keys(styleToAdd)) {
22553
+ const value = styleToAdd[key];
22554
+ if (value === undefined) {
22555
+ continue;
22556
+ }
22557
+ if (key === "transform" || key === "willChange") {
22558
+ style[key] = mergeOneStyle(style[key], value, key, "css");
22559
+ } else {
22560
+ style[key] = value;
22561
+ }
22562
+ }
22563
+ }
22564
+ return style;
22565
+ };
22566
+
22567
+ // The state names a pseudo key asks for, parsed once: the same few keys come
22568
+ // back on every state change of every box that has them. "::x" alone always
22569
+ // matches; "::x:a:b" and ":a:b" ask for ":a" and ":b" — the state keys as
22570
+ // checkPseudoClasses writes them, colon included.
22571
+ const pseudoKeyRequiredStatesMap = new Map();
22572
+ const getPseudoKeyRequiredStates = (pseudoKey) => {
22573
+ const cached = pseudoKeyRequiredStatesMap.get(pseudoKey);
22574
+ if (cached) {
22575
+ return cached;
22576
+ }
22577
+ let requiredStates;
22578
+ if (pseudoKey.startsWith("::")) {
22579
+ const nextColonIndex = pseudoKey.indexOf(":", 2);
22580
+ requiredStates =
22581
+ nextColonIndex === -1
22582
+ ? []
22583
+ : getPseudoKeyRequiredStates(pseudoKey.slice(nextColonIndex));
22584
+ } else {
22585
+ const nextColonIndex = pseudoKey.indexOf(":", 1);
22586
+ requiredStates =
22587
+ nextColonIndex === -1
22588
+ ? [pseudoKey]
22589
+ : pseudoKey
22590
+ .slice(1)
22591
+ .split(":")
22592
+ .map((state) => `:${state}`);
22593
+ }
22594
+ pseudoKeyRequiredStatesMap.set(pseudoKey, requiredStates);
22595
+ return requiredStates;
22596
+ };
22597
+ const hasEveryState = (pseudoState, states) => {
22598
+ for (const state of states) {
22599
+ if (!pseudoState[state]) {
22600
+ return false;
22601
+ }
22602
+ }
22603
+ return true;
22604
+ };
22605
+
22606
+ // element → the style object last written to it, so the next one is written
22607
+ // as a difference: the values that changed, the keys it no longer has.
22608
+ const appliedStyleWeakMap = new WeakMap();
22609
+ const elementTransitionWeakMap = new WeakMap();
22610
+ const elementRenderedWeakSet = new WeakSet();
22611
+ const NO_STYLE = {};
22612
+ const updateStyle = (element, style, preventInitialTransition) => {
22613
+ const styleToApply = style || NO_STYLE;
22614
+ const styleApplied = appliedStyleWeakMap.get(element) || NO_STYLE;
22615
+ // TRANSITION ANTI-FLICKER STRATEGY:
22616
+ // Problem: When setting both transition and styled properties simultaneously
22617
+ // (e.g., el.style.transition = "border-radius 0.3s ease"; el.style.borderRadius = "20px"),
22618
+ // the browser will immediately perform a transition even if no transition existed before.
22619
+ //
22620
+ // Solution: Temporarily disable transitions during initial style application by setting
22621
+ // transition to "none", then restore the intended transition after the frame completes.
22622
+ // We handle multiple updateStyle calls in the same frame gracefully - only one
22623
+ // requestAnimationFrame is scheduled per element, and the final transition value wins.
22624
+ let skipTransition = false;
22625
+ if (!elementRenderedWeakSet.has(element)) {
22626
+ const hasTransition = Object.hasOwn(styleToApply, "transition");
22627
+ if (hasTransition || preventInitialTransition) {
22628
+ if (!elementTransitionWeakMap.has(element)) {
22629
+ element.style.transition = "none";
22630
+ }
22631
+ elementTransitionWeakMap.set(element, styleToApply.transition);
22632
+ // Stays "none" until the first frame puts the intended value back
22633
+ skipTransition = true;
22634
+ }
22635
+ afterFirstFrame(element);
22636
+ }
22637
+
22638
+ for (const key of Object.keys(styleToApply)) {
22639
+ const value = styleToApply[key];
22640
+ if (value === undefined || value === null) {
22641
+ // a removal: handled below with the keys this style no longer has
22642
+ continue;
22643
+ }
22644
+ if (skipTransition && key === "transition") {
22645
+ continue;
22646
+ }
22647
+ if (styleApplied[key] === value) {
22648
+ continue;
22649
+ }
22650
+ if (key.startsWith("--")) {
22651
+ element.style.setProperty(key, value);
22652
+ } else {
22653
+ element.style[key] = value;
22654
+ }
22655
+ }
22656
+
22657
+ for (const key of Object.keys(styleApplied)) {
22658
+ const previousValue = styleApplied[key];
22659
+ if (previousValue === undefined || previousValue === null) {
22660
+ continue;
22661
+ }
22662
+ const value = styleToApply[key];
22663
+ if (value !== undefined && value !== null) {
22664
+ continue;
22665
+ }
22666
+ if (key.startsWith("--")) {
22667
+ element.style.removeProperty(key);
22668
+ } else {
22669
+ element.style[key] = "";
22670
+ }
22671
+ }
22672
+
22673
+ appliedStyleWeakMap.set(element, styleToApply);
22674
+ };
22675
+
22676
+ // One frame for every element waiting for its first one, not one frame each.
22677
+ // What this owes each element — put back the transition suppressed above, and
22678
+ // remember it has now been painted once — is a few microseconds, while asking
22679
+ // the browser for a frame costs more than that, and a page mounting thousands of
22680
+ // boxes asks thousands of times in the same tick. A Set, so an element styled
22681
+ // twice before the frame arrives is still one entry.
22682
+ let elementSetWaitingFirstFrame = new Set();
22683
+ let firstFrameScheduled = false;
22684
+ const afterFirstFrame = (element) => {
22685
+ elementSetWaitingFirstFrame.add(element);
22686
+ if (firstFrameScheduled) {
22687
+ return;
22688
+ }
22689
+ firstFrameScheduled = true;
22690
+ requestAnimationFrame(() => {
22691
+ firstFrameScheduled = false;
22692
+ const elements = elementSetWaitingFirstFrame;
22693
+ elementSetWaitingFirstFrame = new Set();
22694
+ for (const element of elements) {
22695
+ if (elementTransitionWeakMap.has(element)) {
22696
+ const transitionToRestore = elementTransitionWeakMap.get(element);
22697
+ if (transitionToRestore === undefined) {
22698
+ element.style.transition = "";
22699
+ } else {
22700
+ element.style.transition = transitionToRestore;
22701
+ }
22702
+ elementTransitionWeakMap.delete(element);
22703
+ }
22704
+ elementRenderedWeakSet.add(element);
22705
+ }
22706
+ });
22707
+ };
22708
+
22709
+ // Dev only: a periodic re-check for what nothing announces — a state forced
22710
+ // from the devtools. One interval walking every registered check rather than
22711
+ // one per element: a page of a thousand boxes would otherwise run a thousand
22712
+ // timers a second.
22713
+ const devPollCheckSet = new Set();
22714
+ let devPollInterval = null;
22715
+ const pollInDev = (check) => {
22716
+ devPollCheckSet.add(check);
22717
+ if (devPollInterval === null) {
22718
+ devPollInterval = setInterval(() => {
22719
+ for (const registeredCheck of devPollCheckSet) {
22720
+ registeredCheck();
22721
+ }
22722
+ }, 1_000);
22723
+ }
22724
+ return () => {
22725
+ devPollCheckSet.delete(check);
22726
+ if (devPollCheckSet.size === 0) {
22727
+ clearInterval(devPollInterval);
22728
+ devPollInterval = null;
22729
+ }
22730
+ };
22731
+ };
22732
+
19958
22733
  /**
19959
22734
  * Keeps a DOM element in sync with `syncElement(el)` whenever syncElement changes.
19960
22735
  * - If element is already mounted: runs syncElement immediately during render.
@@ -20235,7 +23010,7 @@ import.meta.css = /* css */[`[data-scrollable] > .navi_loading_outline_wrapper,
20235
23010
  }
20236
23011
  }
20237
23012
 
20238
- [hidden] {
23013
+ [hidden]:not([hidden="until-found" i]) {
20239
23014
  display: none !important;
20240
23015
  }
20241
23016
  `, "@jsenv/navi/src/box/box.jsx"];
@@ -39882,7 +42657,14 @@ const useControlProps = (props, {
39882
42657
  persists,
39883
42658
  uiActionInternal
39884
42659
  });
39885
- const [boundAction] = useActionBoundToOneParam(props.action, uiStateController.uiStateSignal);
42660
+ // What the control draws is what it holds by itself, and this read is what
42661
+ // re-renders it when that changes. A button inheriting the value of the
42662
+ // control around it (a submit in a form, see ownUIStateSignal) draws none of
42663
+ // that value, it only hands it to its action: subscribed to it, every button
42664
+ // of a form would re-render at each change of any of its fields.
42665
+ // eslint-disable-next-line no-unused-expressions
42666
+ uiStateController.ownUIStateSignal.value;
42667
+ const boundAction = useAction(props.action, uiStateController.uiStateSignal);
39886
42668
  const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
39887
42669
  uiStateController,
39888
42670
  boundAction,
@@ -40649,7 +43431,10 @@ const useControlProps = (props, {
40649
43431
  };
40650
43432
  }
40651
43433
  }
40652
- const uiState = uiStateController.uiStateSignal.peek();
43434
+
43435
+ // Its own, like syncDomState: a button's DOM value is not the value it
43436
+ // inherits from the control around it.
43437
+ const uiState = uiStateController.ownUIStateSignal.peek();
40653
43438
  const domProps = toDomProps(uiState);
40654
43439
  {
40655
43440
  // Same as syncDomState: a field being typed into keeps its own text, so a
@@ -41198,7 +43983,9 @@ const useInteractiveProps = (props, {
41198
43983
  // stop waiting on a run that is still going. What it waits on is the run
41199
43984
  // it started, from navi_action_start to its settlement.
41200
43985
  const runningAction = uiStateController.runningActionSignal.value;
41201
- const actionStatus = useActionStatus(runningAction || boundAction);
43986
+ // Only whether it runs: the whole status (useActionStatus) reads the params
43987
+ // too, which for a button inheriting its form's value are the form.
43988
+ const actionRunning = (runningAction || boundAction).runningStateSignal.value === RUNNING;
41202
43989
  const networkPolicyReason = useNetworkPolicyReason();
41203
43990
  const {
41204
43991
  disabled,
@@ -41246,7 +44033,7 @@ const useInteractiveProps = (props, {
41246
44033
  // just set stays visible and interactive while the action runs — no
41247
44034
  // loading, no readonly. On failure resetOnError rolls the state back and
41248
44035
  // the error callout says why.
41249
- const actionLoading = optimistic ? false : actionStatus.loading;
44036
+ const actionLoading = optimistic ? false : actionRunning;
41250
44037
  const loadingResolved = loadingBase || actionLoading;
41251
44038
  const readOnlyResolved = readOnlyBase || actionLoading;
41252
44039
  // Read-only, and what this control opens still opens: reading what is in
@@ -41441,7 +44228,11 @@ const useInteractiveProps = (props, {
41441
44228
  errorEffect: actionErrorEffect,
41442
44229
  errorMapping
41443
44230
  });
41444
- const dataAction = action === undefined ? undefined : boundAction.callSource;
44231
+ // What the attribute says is that an action is there (see
44232
+ // findClosestControlWithAction); its text is a label. Peeked (toString),
44233
+ // because `.callSource` follows the params — for a button inheriting its
44234
+ // form's value, a re-render at every change of any field.
44235
+ const dataAction = action === undefined ? undefined : String(boundAction);
41445
44236
  Object.assign(controlHostProps, {
41446
44237
  "data-action": dataAction
41447
44238
  });
@@ -50369,30 +53160,9 @@ const ButtonUI = props => {
50369
53160
  } = buttonControlHostProps;
50370
53161
  const loading = basePseudoState[":-navi-loading"];
50371
53162
 
50372
- // subscribe to document url to re-render and re-compute getHrefTargetInfo
50373
- useDocumentUrl();
50374
- const isLink = href !== undefined;
50375
- let as = "button";
50376
- let innerTarget;
50377
- let innerRel;
50378
- let innerCurrent;
50379
- if (isLink) {
50380
- as = "a";
50381
- const {
50382
- isSameSite,
50383
- isCurrent
50384
- } = getHrefTargetInfo(href);
50385
- innerTarget = target === undefined ? isSameSite ? undefined : "_blank" : target;
50386
- innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
50387
- innerCurrent = isCurrent;
50388
- }
50389
- // For a button that has only an href: nothing else knows it points at the
50390
- // page one is on. A route says so through `pseudoState`, which Box lays over
50391
- // this one.
50392
- buttonControlHostProps.basePseudoState = {
50393
- ...basePseudoState,
50394
- ":-navi-href-current": innerCurrent
50395
- };
53163
+ // Where the href leads next to the page one is on (the target, the rel, the
53164
+ // current state) is resolved above, by ButtonHrefResolver.
53165
+ const as = href === undefined ? "button" : "a";
50396
53166
 
50397
53167
  // Worn as an attribute, like a link's (see link_replace.js): read off the
50398
53168
  // anchor by the click handler, off the source by --navi-nav-to.
@@ -50441,8 +53211,8 @@ const ButtonUI = props => {
50441
53211
  ref: ref,
50442
53212
  as: as,
50443
53213
  href: href,
50444
- target: innerTarget,
50445
- rel: innerRel,
53214
+ target: target,
53215
+ rel: rel,
50446
53216
  replace: undefined,
50447
53217
  ...replaceRequest,
50448
53218
  document: undefined,
@@ -50742,6 +53512,49 @@ const ButtonConfirm = ({
50742
53512
  });
50743
53513
  };
50744
53514
 
53515
+ const ButtonHrefResolver = props => {
53516
+ const Next = useNextResolver();
53517
+ if (props.href !== undefined) {
53518
+ return renderResolver(ButtonWithHref, props);
53519
+ }
53520
+ return jsx(Next, {
53521
+ ...props
53522
+ });
53523
+ };
53524
+
53525
+ // What the href means next to the page one is on depends on the document url,
53526
+ // so a button with an href re-renders at every address write. One without
53527
+ // never reaches here: it has nothing to re-compute, and every button on screen
53528
+ // would otherwise pay for each write.
53529
+ const ButtonWithHref = props => {
53530
+ const Next = useNextResolver();
53531
+ const {
53532
+ href,
53533
+ target,
53534
+ rel,
53535
+ pseudoState
53536
+ } = props;
53537
+ // Read for the subscription: getHrefTargetInfo reads window.location.
53538
+ useDocumentUrl();
53539
+ const {
53540
+ isSameSite,
53541
+ isCurrent
53542
+ } = getHrefTargetInfo(href);
53543
+ return jsx(Next, {
53544
+ ...props,
53545
+ target: target === undefined ? isSameSite ? undefined : "_blank" : target,
53546
+ rel: rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel
53547
+ // Under whatever the caller already says: a route knows better whether
53548
+ // it is the page one is on (see button_route.jsx), and a demo can force
53549
+ // the state.
53550
+ ,
53551
+ pseudoState: {
53552
+ ":-navi-href-current": isCurrent,
53553
+ ...pseudoState
53554
+ }
53555
+ });
53556
+ };
53557
+
50745
53558
  const ButtonRouteResolver = props => {
50746
53559
  const Next = useNextResolver();
50747
53560
  if (props.route) {
@@ -50999,7 +53812,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
50999
53812
  * (it wears it itself) and loses the shrink under the finger, which has
51000
53813
  * nothing left to scale but the interactive area itself.
51001
53814
  */
51002
- const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonUI]);
53815
+ const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonHrefResolver, ButtonUI]);
51003
53816
 
51004
53817
  installImportMetaCssBuild(import.meta);
51005
53818
  const css$O = /* css */`.navi_control_swap {
@@ -68495,12 +71308,28 @@ const css$x = /* css */`@layer navi {
68495
71308
  display: inline-block;
68496
71309
  }
68497
71310
 
71311
+ .navi_list_find_stand_in {
71312
+ height: calc(var(--x-find-line-count) * var(--x-find-line-size));
71313
+ color: #0000;
71314
+ line-height: var(--x-find-line-size);
71315
+ white-space: pre;
71316
+ overflow: clip;
71317
+ }
71318
+
68498
71319
  .navi_list_container[data-horizontal] {
68499
71320
  --list-max-height: none;
68500
71321
 
68501
71322
  & > .navi_list_scroll_container > .navi_list > .navi_list_virtual_filler {
68502
71323
  width: var(--size-to-fill, 0px);
68503
71324
  height: 100%;
71325
+ display: flex;
71326
+
71327
+ & > .navi_list_find_stand_in {
71328
+ width: calc(var(--x-find-line-count) * var(--x-find-line-size));
71329
+ writing-mode: vertical-lr;
71330
+ flex-shrink: 0;
71331
+ height: auto;
71332
+ }
68504
71333
  }
68505
71334
  }
68506
71335
 
@@ -71097,10 +73926,12 @@ const Fallback = ({
71097
73926
  // settled after the first commit.
71098
73927
  const VirtualFiller = ({
71099
73928
  edge,
71100
- itemCount
73929
+ itemCount,
73930
+ findChunks
71101
73931
  }) => {
71102
73932
  const listRows = useContext(ListRowsContext);
71103
- const sizeToFill = itemCount * listRows.virtualItemSizeSignal.value;
73933
+ const virtualItemSize = listRows.virtualItemSizeSignal.value;
73934
+ const sizeToFill = itemCount * virtualItemSize;
71104
73935
  if (!sizeToFill) {
71105
73936
  return null;
71106
73937
  }
@@ -71111,8 +73942,17 @@ const VirtualFiller = ({
71111
73942
  "navi-virtual-filler": edge,
71112
73943
  "aria-hidden": true,
71113
73944
  style: {
71114
- "--size-to-fill": `${sizeToFill}px`
71115
- }
73945
+ "--size-to-fill": `${sizeToFill}px`,
73946
+ "--x-find-line-size": findChunks ? `${virtualItemSize}px` : undefined
73947
+ },
73948
+ children: findChunks && findChunks.map(chunk => jsx("div", {
73949
+ className: "navi_list_find_stand_in",
73950
+ hidden: "until-found",
73951
+ style: {
73952
+ "--x-find-line-count": chunk.to - chunk.from
73953
+ },
73954
+ children: chunk.text
73955
+ }, `${chunk.from}_${chunk.to}`))
71116
73956
  });
71117
73957
  };
71118
73958
 
@@ -71748,6 +74588,7 @@ const VISIBILITY_HIDDEN_STYLE = {
71748
74588
  *
71749
74589
  * @type {import("ignore:preact").FunctionComponent<{
71750
74590
  * renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
74591
+ * findText?: (item: any, index: number) => string,
71751
74592
  * items?: any[],
71752
74593
  * itemsAction?: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
71753
74594
  * count?: number,
@@ -71766,6 +74607,13 @@ const VISIBILITY_HIDDEN_STYLE = {
71766
74607
  * the run. `state.refreshing` says the rows drawn are the ones from before
71767
74608
  * while the run reads the collection again — the list carries
71768
74609
  * `navi-refreshing` for the same reason.
74610
+ * @param {(item: any, index: number) => string} [props.findText]
74611
+ * The text of a row as the browser's find in page (Cmd/Ctrl + F) sees it
74612
+ * while the row is not drawn — without it, find reaches only the rows in the
74613
+ * render window. The rows held off screen then carry that text where they
74614
+ * stand, hidden until the browser finds it there: it scrolls to the place,
74615
+ * and the list draws the row. One line per row. A row the run does not hold
74616
+ * yet has nothing to be found by.
71769
74617
  * @param {any[]} [props.items]
71770
74618
  * The collection, when it is held in memory: all of it, in order. Nothing is
71771
74619
  * ever asked for — `itemsAction`, `count`, `pageSize` and `memoryBudget` have
@@ -71862,12 +74710,19 @@ const createStoreHoldingNothing = rowCount => {
71862
74710
  };
71863
74711
  };
71864
74712
 
74713
+ // How many rows share one element of the text a filler carries for find in
74714
+ // page (see findText). One element per row would put back the DOM nodes the
74715
+ // window saves; one per filler would be revealed whole — every row of it laid
74716
+ // out — by the first match found in it.
74717
+ const FIND_CHUNK_ROW_COUNT = 64;
74718
+
71865
74719
  // What a run draws: the rows its window frames, a skeleton for each one the
71866
74720
  // store does not hold, and fillers holding the room of the rows outside it.
71867
74721
  // Which rows are held, and fetching the others, is the store's (see
71868
74722
  // useItemStore).
71869
74723
  const useRunRows = (store, {
71870
74724
  renderItem,
74725
+ findText,
71871
74726
  pageSize,
71872
74727
  groupBy,
71873
74728
  renderGroupLabel,
@@ -72075,6 +74930,37 @@ const useRunRows = (store, {
72075
74930
  }
72076
74931
  group.children.push(rowNode);
72077
74932
  };
74933
+ // The text of the rows a filler stands for, cut in chunks aligned on the
74934
+ // run's own ranks: the window sliding changes the chunk at its edge and
74935
+ // leaves the others as they are.
74936
+ const getFindChunks = (from, to) => {
74937
+ if (!findText) {
74938
+ return null;
74939
+ }
74940
+ const chunks = [];
74941
+ let chunkFrom = from;
74942
+ while (chunkFrom < to) {
74943
+ const chunkIndex = Math.floor(rankOf(chunkFrom) / FIND_CHUNK_ROW_COUNT);
74944
+ const alignedTo = rowOf((chunkIndex + 1) * FIND_CHUNK_ROW_COUNT);
74945
+ const chunkTo = alignedTo < to ? alignedTo : to;
74946
+ const lines = [];
74947
+ let lineRowIndex = chunkFrom;
74948
+ while (lineRowIndex < chunkTo) {
74949
+ const item = getItemAt(lineRowIndex);
74950
+ // A row not held has nothing to find, but keeps its line: the rows
74951
+ // after it stay at their place.
74952
+ lines.push(item === undefined ? "" : toFindLine(findText(item, lineRowIndex)));
74953
+ lineRowIndex++;
74954
+ }
74955
+ chunks.push({
74956
+ from: chunkFrom,
74957
+ to: chunkTo,
74958
+ text: lines.join("\n")
74959
+ });
74960
+ chunkFrom = chunkTo;
74961
+ }
74962
+ return chunks;
74963
+ };
72078
74964
  // The room held for this run's own rows that the window leaves out. It
72079
74965
  // belongs to the run and not to the list: a list is not necessarily made of
72080
74966
  // one run, and what sits before or after it (a header, rows given one by
@@ -72082,7 +74968,8 @@ const useRunRows = (store, {
72082
74968
  if (windowFrom > runStart) {
72083
74969
  rows.push(jsx(VirtualFiller, {
72084
74970
  edge: "before",
72085
- itemCount: windowFrom - runStart
74971
+ itemCount: windowFrom - runStart,
74972
+ findChunks: getFindChunks(runStart, windowFrom)
72086
74973
  }, "navi-list-filler-before"));
72087
74974
  }
72088
74975
  const renderItemState = {
@@ -72184,11 +75071,20 @@ const useRunRows = (store, {
72184
75071
  if (runEnd > windowTo) {
72185
75072
  rows.push(jsx(VirtualFiller, {
72186
75073
  edge: "after",
72187
- itemCount: runEnd - windowTo
75074
+ itemCount: runEnd - windowTo,
75075
+ findChunks: getFindChunks(windowTo, runEnd)
72188
75076
  }, "navi-list-filler-after"));
72189
75077
  }
72190
75078
  return rows;
72191
75079
  };
75080
+ // One row, one line: a line break inside it would push every row below off
75081
+ // its place.
75082
+ const toFindLine = text => {
75083
+ if (text === undefined || text === null) {
75084
+ return "";
75085
+ }
75086
+ return String(text).replace(/[\r\n]+/g, " ");
75087
+ };
72192
75088
 
72193
75089
  // A run's row that has not arrived, standing where the real one will. It never
72194
75090
  // reaches ListItemUI (see ListItemSkeletonResolver), so it is drawn among the
@@ -80214,6 +83110,7 @@ const useWheelInteractions = ({
80214
83110
  attemptInteraction,
80215
83111
  cancelAnim,
80216
83112
  setPos,
83113
+ releaseScrub,
80217
83114
  snapPosToRow,
80218
83115
  getItemSize,
80219
83116
  viewportMain,
@@ -80622,6 +83519,8 @@ const useWheelInteractions = ({
80622
83519
  clearTimeout(settleTimer);
80623
83520
  stopClaimingGesture();
80624
83521
  cancelAnim();
83522
+ // Nothing is left to settle the scrub in progress.
83523
+ releaseScrub();
80625
83524
  document.removeEventListener("pointerdown", onDocumentPointerDown, {
80626
83525
  capture: true
80627
83526
  });
@@ -80787,11 +83686,11 @@ function WheelUI(props) {
80787
83686
  ...props,
80788
83687
  ref: inputRef,
80789
83688
  type: "navi_js",
80790
- // uiAction fires live (every center-crossing, via setPos live reports);
80791
- // the committed action must fire only when the wheel stabilizes. "custom"
80792
- // stops setUIState from auto-firing the action on every value change, so
80793
- // the live reports are uiAction-only; commitSelection then dispatches the
80794
- // action explicitly on settle. A caller can still override actionEvent.
83689
+ // An arrow or a tap changes the value at once while the row glides into
83690
+ // place, and the action must wait for the wheel to come to rest. "custom"
83691
+ // stops setUIState from auto-firing the action on every value change;
83692
+ // commitSelection dispatches it explicitly on settle. A caller can still
83693
+ // override actionEvent.
80795
83694
  actionEvent: props.actionEvent ?? "custom"
80796
83695
  }, {
80797
83696
  controlType: "input"
@@ -80887,6 +83786,11 @@ function WheelUI(props) {
80887
83786
  // while our own scroll-driven selection does not scroll a second time. Holds the
80888
83787
  // TARGET so rapid inputs step from it and accumulate. null = nothing pending.
80889
83788
  const centeredIndexRef = useRef(null);
83789
+ // A user scrub moved the wheel onto a row that is not the value yet: set at
83790
+ // the first row it crosses, cleared when it settles (commitSelection), when
83791
+ // an arrow or a tap sets a value at once (glideToIndex), or when it is
83792
+ // dropped (releaseScrub). See setPos.
83793
+ const scrubHoldsRowRef = useRef(false);
80890
83794
  // The wheel does NOT use native scroll. `posRef` is our own virtual scroll
80891
83795
  // position (px, main axis); the list track is translated by -pos. This is the
80892
83796
  // single source of truth, wrapped modulo the real-list extent in loop mode, so
@@ -80936,8 +83840,22 @@ function WheelUI(props) {
80936
83840
  ...style
80937
83841
  };
80938
83842
 
80939
- // Main-axis size of the viewport, from the orientation.
80940
- const viewportMain = vp => isHorizontal ? vp.clientWidth : vp.clientHeight;
83843
+ // Main-axis size of the viewport, from the orientation. Cached like the row
83844
+ // size: applyOffset runs right after each window commit, where reading the
83845
+ // box would force a layout at every row crossing. The observer below keeps it
83846
+ // current; 0 (no box yet) is never kept, so the next read measures again.
83847
+ const viewportSizeRef = useRef(0);
83848
+ const measureViewportMain = vp => isHorizontal ? vp.clientWidth : vp.clientHeight;
83849
+ const viewportMain = vp => {
83850
+ if (viewportSizeRef.current > 0) {
83851
+ return viewportSizeRef.current;
83852
+ }
83853
+ const size = measureViewportMain(vp);
83854
+ if (size > 0) {
83855
+ viewportSizeRef.current = size;
83856
+ }
83857
+ return size;
83858
+ };
80941
83859
 
80942
83860
  // Row size (px). Rows are uniform, so one measurement (cached) drives all the
80943
83861
  // geometry — positions are then computed from value indices, not read off the
@@ -81008,6 +83926,21 @@ function WheelUI(props) {
81008
83926
  applyOffset(vp);
81009
83927
  }
81010
83928
  };
83929
+ // Refreshes the cached viewport size (see viewportMain). Read in the observer
83930
+ // callback, where the layout is already clean. A new size moves the center
83931
+ // window, so the offset is re-applied with it.
83932
+ useLayoutEffect(() => {
83933
+ const vp = getViewport();
83934
+ viewportSizeRef.current = 0;
83935
+ const resizeObserver = new ResizeObserver(() => {
83936
+ viewportSizeRef.current = measureViewportMain(vp);
83937
+ applyOffset(vp);
83938
+ });
83939
+ resizeObserver.observe(vp);
83940
+ return () => {
83941
+ resizeObserver.disconnect();
83942
+ };
83943
+ }, [isHorizontal]);
81011
83944
 
81012
83945
  // The value index at the center for the current position.
81013
83946
  const centeredIndex = vp => {
@@ -81046,12 +83979,14 @@ function WheelUI(props) {
81046
83979
  posRef.current = clampNumber(posRef.current, 0, (count - 1) * size);
81047
83980
  };
81048
83981
 
81049
- // `live` (user scrubs: drag, momentum, wheel scroll) reports the value as each
81050
- // item crosses the center, so uiAction tracks the wheel in real time — like
81051
- // every other control. The committed action still fires only once it settles
81052
- // (commitSelection). A programmatic move (centerOnIndex on an external value
81053
- // sync) omits it, so scrolling the wheel into place doesn't echo the value
81054
- // back out.
83982
+ // `live` marks a user scrub (drag, momentum, wheel scroll). A scrub moves the
83983
+ // drawing, not the value: the row it reaches is held by the wheel, and only
83984
+ // commitSelection turns it into the value, once the motion settles. Reported
83985
+ // at each crossing, every notch would be a value change for all that is
83986
+ // bound to the wheel — its group, its form, a signal, the address, which
83987
+ // records where a value was put down, not the path the finger took
83988
+ // (navigation.md, "A value written per frame"). A programmatic move
83989
+ // (centerOnIndex) omits `live`: it places the value the wheel already has.
81055
83990
  const setPos = (vp, pos, {
81056
83991
  live = false
81057
83992
  } = {}) => {
@@ -81062,18 +83997,25 @@ function WheelUI(props) {
81062
83997
  renderPos(vp);
81063
83998
  if (live && interactive) {
81064
83999
  const index = centeredIndex(vp);
81065
- if (index !== centeredIndexRef.current) {
81066
- const item = trackedItemsRef.current[index];
81067
- if (item) {
81068
- // Keep centeredIndexRef in step so the every-render
81069
- // syncCenterToSelection doesn't read this as an external change and
81070
- // fight the in-progress motion.
81071
- centeredIndexRef.current = index;
81072
- requestSelectValue(item.value, new CustomEvent("navi_wheel_scrub"));
81073
- }
84000
+ if (index !== centeredIndexRef.current && trackedItemsRef.current[index]) {
84001
+ // In step with the scrub so the next arrow or tap steps from the row
84002
+ // the wheel is on (see currentTargetIndex).
84003
+ centeredIndexRef.current = index;
84004
+ scrubHoldsRowRef.current = true;
81074
84005
  }
81075
84006
  }
81076
84007
  };
84008
+ // The scrub was dropped before it settled (the wheel stopped being
84009
+ // interactive, or its input bindings were torn down): the row it held never
84010
+ // became the value. Forgetting it lets the next sync put the wheel back on
84011
+ // the value.
84012
+ const releaseScrub = () => {
84013
+ if (!scrubHoldsRowRef.current) {
84014
+ return;
84015
+ }
84016
+ scrubHoldsRowRef.current = false;
84017
+ centeredIndexRef.current = null;
84018
+ };
81077
84019
 
81078
84020
  // Returns whether it actually interrupted a motion in flight — lets the caller
81079
84021
  // tell a fresh gesture from one that "caught" a spinning/gliding wheel.
@@ -81109,6 +84051,7 @@ function WheelUI(props) {
81109
84051
  renderPos(vp);
81110
84052
  }
81111
84053
  centeredIndexRef.current = index;
84054
+ scrubHoldsRowRef.current = false;
81112
84055
  if (!interactive || glideFromOutsideRef.current) {
81113
84056
  return;
81114
84057
  }
@@ -81313,6 +84256,7 @@ function WheelUI(props) {
81313
84256
  // inputs accumulate.
81314
84257
  const glideToIndex = (vp, index, event) => {
81315
84258
  centeredIndexRef.current = index;
84259
+ scrubHoldsRowRef.current = false;
81316
84260
  requestSelectValue(trackedItemsRef.current[index].value, event);
81317
84261
  glideTo(vp, glideTargetFor(vp, index));
81318
84262
  };
@@ -81343,12 +84287,12 @@ function WheelUI(props) {
81343
84287
  // Sync the center with the current value — used on first display and whenever
81344
84288
  // the controlled value changes from outside.
81345
84289
  const syncCenterToSelection = (viewportEl, behavior) => {
81346
- // A fling in flight reports its own value at every row crossing and commits
81347
- // on settle: the finger that threw it owns the wheel until then, and a value
81348
- // arriving meanwhile is re-stated by the next crossing anyway. A glide is
81349
- // different: its target is re-aimed below (glideTo never restarts the
81350
- // loop), so a value set from outside mid-glide is where the wheel ends up.
81351
- if (momentumRef.current !== null) {
84290
+ // A fling in flight, or a scrub holding a row, commits on settle: the finger
84291
+ // owns the wheel until then, and the row it leaves the wheel on overrules a
84292
+ // value arriving meanwhile. A glide is different: its target is re-aimed
84293
+ // below (glideTo never restarts the loop), so a value set from outside
84294
+ // mid-glide is where the wheel ends up.
84295
+ if (momentumRef.current !== null || scrubHoldsRowRef.current) {
81352
84296
  return;
81353
84297
  }
81354
84298
  if (trackedItemsRef.current.length === 0) {
@@ -81431,6 +84375,7 @@ function WheelUI(props) {
81431
84375
  attemptInteraction,
81432
84376
  cancelAnim,
81433
84377
  setPos,
84378
+ releaseScrub,
81434
84379
  snapPosToRow,
81435
84380
  getItemSize,
81436
84381
  viewportMain,
@@ -88669,6 +91614,105 @@ const createSlot = (SlotRenderer = Box) => {
88669
91614
  return [Slot, SlotFill];
88670
91615
  };
88671
91616
 
91617
+ /**
91618
+ * The on-screen keyboard overlaying the app rather than resizing the viewport,
91619
+ * for an app that asks for it (the VirtualKeyboard API — Chromium only). See
91620
+ * virtual_keyboard.js in @jsenv/dom for what that trades away and what it
91621
+ * gives back.
91622
+ *
91623
+ * Offered, never taken by default. Overlaying means redoing by hand what the
91624
+ * browser does for a viewport that shrinks, and on a phone every piece of it
91625
+ * hid a Chrome behavior nothing reports: the focused field left under the
91626
+ * keyboard, a suggestion strip painted above the keyboard outside its
91627
+ * geometry, geometrychange firing while the page is merely scrolled. Firefox
91628
+ * and Safari shrink the viewport whatever the app wants, and navi follows that
91629
+ * already (--navi-vvh), so the default is the path every browser shares.
91630
+ *
91631
+ * What the overlay buys: no reflow of the page underneath — a resizing
91632
+ * viewport is a resize of everything, fired transiently every time focus goes
91633
+ * from one input to the next — while navi still sizes what escapes normal flow
91634
+ * against a rectangle the keyboard is subtracted from
91635
+ * (--navi-keyboard-inset-bottom, safe_area.js).
91636
+ *
91637
+ * Taking the deal means taking over what the browser stops doing under it:
91638
+ * bringing the focused field out from under the keyboard. A viewport that
91639
+ * shrinks makes the browser scroll to the field; a keyboard that merely paints
91640
+ * over the page leaves whatever is under it under it. So enabling the overlay
91641
+ * also installs that scroll, with two things safe_area.js provides: a
91642
+ * scroll-padding-bottom that counts the keyboard in, plus an allowance for the
91643
+ * strip Chrome paints above it without reporting it
91644
+ * (--navi-keyboard-strip-allowance), which bounds the band the field is brought
91645
+ * into; and room at the end of the scroller, so a field near the end of the
91646
+ * page has somewhere to go. All of it reads 0 while the keyboard does not
91647
+ * overlay.
91648
+ *
91649
+ * Only the arrival is navi's: once the field is in view, Chrome keeps the caret
91650
+ * there itself as typing grows a textarea, against that same
91651
+ * scroll-padding-bottom.
91652
+ */
91653
+
91654
+
91655
+ /**
91656
+ * Makes the on-screen keyboard overlay the app instead of shrinking the
91657
+ * viewport, and brings the focused field out from under it. A no-op on
91658
+ * Firefox/Safari, which have no VirtualKeyboard API.
91659
+ *
91660
+ * @returns {() => void} Goes back to the viewport shrinking.
91661
+ */
91662
+ const enableVirtualKeyboardOverlay = () => {
91663
+ if (!setVirtualKeyboardOverlaysContent(true)) {
91664
+ return () => {};
91665
+ }
91666
+ // The keyboard rising, or changing height (suggestion strip, emoji panel).
91667
+ const unsubscribeGeometryChange =
91668
+ subscribeVirtualKeyboardGeometryChange(revealFocusedField);
91669
+ // From one field to the next with the keyboard up: when both want the same
91670
+ // keyboard it does not move, and no geometrychange fires.
91671
+ document.addEventListener("focusin", revealFocusedField, { capture: true });
91672
+ return () => {
91673
+ unsubscribeGeometryChange();
91674
+ document.removeEventListener("focusin", revealFocusedField, {
91675
+ capture: true,
91676
+ });
91677
+ setVirtualKeyboardOverlaysContent(false);
91678
+ };
91679
+ };
91680
+
91681
+ const revealFocusedField = () => {
91682
+ if (getVirtualKeyboardOverlayHeight() === 0) {
91683
+ return;
91684
+ }
91685
+ const field = document.activeElement;
91686
+ if (!isEditableTarget(field)) {
91687
+ return;
91688
+ }
91689
+ if (isInDocumentBand(field)) {
91690
+ return;
91691
+ }
91692
+ // "center" rather than "nearest": nearest stops the field on the band's
91693
+ // edge, clear of the strip only by the allowance. The middle of the band
91694
+ // clears a strip taller than guessed too — and centering what is not fully
91695
+ // visible is what Chrome does itself when the viewport shrinks.
91696
+ scrollIntoViewThroughScrollables(field, {
91697
+ block: "center",
91698
+ behavior: "instant",
91699
+ });
91700
+ };
91701
+ // The band the document leaves visible: inside its scroll-padding (the bars,
91702
+ // the keyboard and its strip, see safe_area.js). Against the window rather
91703
+ // than each scroller's own band: the keyboard covers the window, and a field
91704
+ // hidden inside its own scroller is what the browser's focus already scrolls
91705
+ // to.
91706
+ const isInDocumentBand = (field) => {
91707
+ const documentElement = document.documentElement;
91708
+ const style = getComputedStyle(documentElement);
91709
+ const bandTop = parseFloat(style.scrollPaddingTop) || 0;
91710
+ const bandBottom =
91711
+ documentElement.clientHeight - (parseFloat(style.scrollPaddingBottom) || 0);
91712
+ const { top, bottom } = field.getBoundingClientRect();
91713
+ return top >= bandTop && bottom <= bandBottom;
91714
+ };
91715
+
88672
91716
  /*
88673
91717
  * - Usage
88674
91718
  * useEffect(() => {
@@ -88832,5 +91876,5 @@ const UserSvg = () => jsx("svg", {
88832
91876
  })
88833
91877
  });
88834
91878
 
88835
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NetworkPolicyError, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, dispatchRequestSetUIState, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, isCellSelected, isColumnSelected, isNetworkPolicyError, isRowSelected, isScrolling, languagesSignal, localStorageSignal, markAsOutsideTextFlow, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, preloadUrl, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, routeFallback, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePickerState, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
91879
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NetworkPolicyError, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, dispatchRequestSetUIState, enableDebugActions, enableDebugOnDocumentLoading, enableVirtualKeyboardOverlay, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, getNowHours, getNowHoursRoundedToStep, isCellSelected, isColumnSelected, isNetworkPolicyError, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markAsOutsideTextFlow, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, preloadUrl, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, routeFallback, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePickerState, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
88836
91880
  //# sourceMappingURL=jsenv_navi.js.map