@jsenv/navi 0.29.402 → 0.29.404

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,9 @@
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, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
- export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- 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, 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, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
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, 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, 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, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { options, isValidElement, createContext, render, h, toChildArray, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
@@ -12,7 +12,7 @@ import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, use
12
12
  import { humanizeI18n, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
13
13
  export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
14
14
  import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
15
- import { 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";
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";
16
16
  export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
17
17
  import { Suspense, createPortal, forwardRef } from "preact/compat";
18
18
 
@@ -1577,27 +1577,6 @@ naviI18n.addAll({
1577
1577
  },
1578
1578
  });
1579
1579
 
1580
- const isSignal = (value) => {
1581
- return getSignalType(value) !== null;
1582
- };
1583
-
1584
- const BRAND_SYMBOL = Symbol.for("preact-signals");
1585
- const getSignalType = (value) => {
1586
- if (!value || typeof value !== "object") {
1587
- return null;
1588
- }
1589
-
1590
- if (value.brand !== BRAND_SYMBOL) {
1591
- return null;
1592
- }
1593
-
1594
- if (typeof value._fn === "function") {
1595
- return "computed";
1596
- }
1597
-
1598
- return "signal";
1599
- };
1600
-
1601
1580
  /*
1602
1581
  * The network policy: one declaration saying whether a request may go out,
1603
1582
  * read by the action layer rather than by every callback.
@@ -4757,1118 +4736,6 @@ const actionRunEffect = (
4757
4736
  return actionRunnedByThisEffect;
4758
4737
  };
4759
4738
 
4760
- /**
4761
- * DOM utilities for navigating the control element hierarchy.
4762
- *
4763
- * A control is a self-contained interactive widget. Its DOM structure can be
4764
- * either flat (host only) or layered (wrapper + host):
4765
- *
4766
- * Flat — the element is both the root and the host:
4767
- * ```html
4768
- * <button navi-control navi-control-host>Click me</button>
4769
- * ```
4770
- *
4771
- * Layered — a visual wrapper surrounds a native input that is the real host:
4772
- * ```html
4773
- * <span navi-control> ← wrapper: root of the control's DOM subtree
4774
- * <input navi-control-host /> ← host: holds controlProps, value, UI state, constraints
4775
- * </span>
4776
- * ```
4777
- *
4778
- * Attribute roles:
4779
- * - `navi-control` boolean, on the wrapper/root; marks the control boundary
4780
- * - `navi-control-host` boolean, on the host; set automatically by `useInteractiveProps`
4781
- *
4782
- * See control_proxy.js for the `navi-control-proxy-for` pattern.
4783
- */
4784
-
4785
- /**
4786
- * Returns the host element inside `el` — the element that holds the control's
4787
- * value, UI state, and constraints (i.e. the element onto which
4788
- * `useInteractiveProps` spreads `controlProps` and its event handlers).
4789
- *
4790
- * Returns `null` when `el` is itself the host (no separate wrapper).
4791
- */
4792
- const findControlHost = (el) => {
4793
- if (el.hasAttribute("navi-control-host")) {
4794
- return el;
4795
- }
4796
- return el.querySelector("[navi-control-host]");
4797
- };
4798
- /**
4799
- * The control this element belongs to, wherever it sits relative to it.
4800
- *
4801
- * `findControlHost` above answers for a control's own DOM (itself, or the native
4802
- * element it wraps). This one is for something that is not part of a control but
4803
- * has to reach one — an interaction declared on a box (see
4804
- * interaction/interactions.js), which may be the control, may sit inside it, or
4805
- * may be a wrapper around exactly one. Nearest wins in that order, and the answer
4806
- * is null when there is no control the box belongs to: not every box lives in
4807
- * one, and a box laying out several controls (a row of badges, a toolbar) is
4808
- * held by none of them — its interactions are its own, and a press on its empty
4809
- * part is not a press on the first control it happens to contain.
4810
- */
4811
- const findNearestControlHost = (el) => {
4812
- // Itself, then upwards — the box is inside a button, or is one.
4813
- const selfOrAncestor = el.closest("[navi-control-host]");
4814
- if (selfOrAncestor) {
4815
- return selfOrAncestor;
4816
- }
4817
- // …then downwards, only when the box wraps a single control. Counted by
4818
- // control, not by host: a picker holds the hosts of its popup content, and a
4819
- // box around that one picker is still around one control.
4820
- let single = null;
4821
- for (const host of el.querySelectorAll("[navi-control-host]")) {
4822
- const controlRoot = host.closest("[navi-control]") || host;
4823
- const controlAbove = controlRoot.parentElement.closest("[navi-control]");
4824
- if (controlAbove && el.contains(controlAbove)) {
4825
- continue;
4826
- }
4827
- if (single && single !== controlRoot) {
4828
- return null;
4829
- }
4830
- single = controlRoot;
4831
- }
4832
- return single ? findControlHost(single) : null;
4833
- };
4834
- const isControlRoot = (el) => {
4835
- return el.hasAttribute("navi-control");
4836
- };
4837
- const isControlHost = (el) => {
4838
- return el.hasAttribute("navi-control-host");
4839
- };
4840
- const isControl = (el) => {
4841
- return isControlRoot(el) || isControlHost(el);
4842
- };
4843
-
4844
- /**
4845
- * Returns the nearest ancestor of `el` (exclusive of `el`'s own control) that
4846
- * has a `[data-action]` attribute.
4847
- *
4848
- * The search walks up `parentElement` manually (rather than using `.closest()`)
4849
- * so it can stop at hard boundaries.
4850
- *
4851
- * **`[navi-control="picker"]` boundary**: a picker is a hard stop for what is
4852
- * inside its popup. Elements in there can reach the picker itself, but nothing
4853
- * above it — an input inside a picker must not submit the form around it. The
4854
- * picker's own trigger is not inside it: a picker pressed as a whole (a confirm
4855
- * picker sending the form around it, see picker_confirm.jsx) stands in that
4856
- * form like any control there, and reaches it.
4857
- *
4858
- * ```html
4859
- * <form data-action="outer"> ← found from the picker's own input,
4860
- * <button navi-control="picker" data-action="p"> NOT from inside its popup
4861
- * <input navi-control-host /> ← el (the picker's own input)
4862
- * <div popover data-picker-content>
4863
- * <input navi-control-host /> ← el (in picker popover): stops at the picker
4864
- * </div>
4865
- * </button>
4866
- * </form>
4867
- * ```
4868
- */
4869
- const findClosestControlWithAction = (el) => {
4870
- let current = el;
4871
- while (current) {
4872
- if (current.hasAttribute("data-action")) {
4873
- return current;
4874
- }
4875
- if (isPickerBoundaryFor(current, el)) {
4876
- return undefined;
4877
- }
4878
- current = current.parentElement;
4879
- }
4880
- return undefined;
4881
- };
4882
- const isPickerBoundaryFor = (candidate, el) => {
4883
- if (candidate.getAttribute("navi-control") !== "picker") {
4884
- return false;
4885
- }
4886
- const content = el.closest("[data-picker-content]");
4887
- return Boolean(content) && candidate.contains(content);
4888
- };
4889
-
4890
- /**
4891
- * Returns the closest ancestor control element of `el` — i.e. the nearest
4892
- * `[navi-control]` element that is not the control `el` belongs to.
4893
- *
4894
- * `navi-control` is only on wrapper elements, never on hosts. So
4895
- * `el.closest("[navi-control]")` from a host returns that host's own wrapper,
4896
- * and one more `.parentNode.closest("[navi-control]")` reaches a true ancestor:
4897
- *
4898
- * ```html
4899
- * <button navi-control> ← outer control (returned)
4900
- * <span navi-control> ← inner wrapper (skipped via parentNode)
4901
- * <input navi-control-host /> ← el
4902
- * </span>
4903
- * </button>
4904
- * ```
4905
- */
4906
- const getParentControl = (el) => {
4907
- const ownControlRoot = el.closest("[navi-control]");
4908
- const parentControlRoot = ownControlRoot.parentNode.closest("[navi-control]");
4909
- return parentControlRoot;
4910
- };
4911
-
4912
- /**
4913
- * Returns the root element of the control that `el` belongs to, or `null` if
4914
- * `el` is not part of a control.
4915
- *
4916
- * Use this when you have an element that may be a host (inner input) and need
4917
- * the visual boundary of its control — e.g. to anchor a callout, track
4918
- * mousedown interactions, or measure the control's bounding box.
4919
- */
4920
- const findControlRoot = (el) => {
4921
- if (el.hasAttribute("navi-control")) {
4922
- return el;
4923
- }
4924
- if (el.hasAttribute("navi-control-host")) {
4925
- return el.closest("[navi-control]");
4926
- }
4927
- return null;
4928
- };
4929
-
4930
- /**
4931
- * Parses a time string into seconds.
4932
- * Accepts:
4933
- * - number: returned as-is (already in seconds)
4934
- * - "HH:MM" string: converted to seconds (e.g. "00:30" → 1800, "01:00" → 3600)
4935
- * - undefined/null: returned as-is
4936
- */
4937
- const timeStringToSeconds = (timeString) => {
4938
- if (typeof timeString !== "string") {
4939
- return timeString;
4940
- }
4941
- const colonIndex = timeString.indexOf(":");
4942
- if (colonIndex === -1) {
4943
- return Number(timeString);
4944
- }
4945
- const hours = parseInt(timeString.slice(0, colonIndex), 10);
4946
- const minutes = parseInt(timeString.slice(colonIndex + 1), 10);
4947
- return (hours * 60 + minutes) * 60;
4948
- };
4949
-
4950
- const isToday = (value) => {
4951
- if (!value) {
4952
- return false;
4953
- }
4954
- const now = new Date();
4955
- const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
4956
- if (typeof value === "string") {
4957
- return value === todayStr;
4958
- }
4959
- if (typeof value === "number") {
4960
- const d = new Date(value);
4961
- const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
4962
- return s === todayStr;
4963
- }
4964
- if (value instanceof Date) {
4965
- const s = `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
4966
- return s === todayStr;
4967
- }
4968
- return false;
4969
- };
4970
-
4971
- /**
4972
- * Returns the current time as "HH:MM", with an optional minute offset.
4973
- *
4974
- * @param {number} [offsetMinutes=0] - Minutes to add (negative = subtract).
4975
- * E.g. getNowHours(-5) returns "now minus 5 minutes".
4976
- *
4977
- * @example
4978
- * getNowHours() // "14:30"
4979
- * getNowHours(-5) // "14:25"
4980
- */
4981
- const getNowHours = (offsetMinutes = 0) => {
4982
- const now = new Date();
4983
- const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
4984
- const clamped =
4985
- totalMinutes < 0
4986
- ? 0
4987
- : totalMinutes > 23 * 60 + 59
4988
- ? 23 * 60 + 59
4989
- : totalMinutes;
4990
- const h = Math.floor(clamped / 60);
4991
- const m = clamped % 60;
4992
- return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
4993
- };
4994
-
4995
- /**
4996
- * Returns the current time rounded up to the nearest step boundary,
4997
- * with an optional minute offset applied first.
4998
- *
4999
- * This is useful to compute a step-aligned `min` for a time picker:
5000
- * passing it ensures the first available slot is always on a step boundary.
5001
- *
5002
- * @param {number} stepMinutes - Step size in minutes (e.g. 30).
5003
- * @param {number} [offsetMinutes=0] - Minutes to add before rounding (negative = subtract).
5004
- *
5005
- * @example
5006
- * // At 9:32, step 30, offset -5 → raw = 9:27 → ceil to 30 → "09:30"
5007
- * // At 9:38, step 30, offset -5 → raw = 9:33 → ceil to 30 → "10:00"
5008
- * getNowHoursRoundedToStep(30, -5)
5009
- */
5010
- const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
5011
- const now = new Date();
5012
- const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
5013
- const aligned = Math.ceil(totalMinutes / stepMinutes) * stepMinutes;
5014
- const clamped =
5015
- aligned < 0 ? 0 : aligned > 23 * 60 + 59 ? 23 * 60 + 59 : aligned;
5016
- const h = Math.floor(clamped / 60);
5017
- const m = clamped % 60;
5018
- return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
5019
- };
5020
-
5021
- /**
5022
- * "HH:MM" and its two numbers, in both directions — what any control made of an
5023
- * hour beside a minute (fields, wheels) aggregates to and is placed from. Held
5024
- * as numbers, written on two digits: how they are shown is each control's own
5025
- * business.
5026
- */
5027
- const parseTimeParts = (time) => {
5028
- if (typeof time !== "string") {
5029
- return null;
5030
- }
5031
- const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
5032
- if (!match) {
5033
- return null;
5034
- }
5035
- return { hour: Number(match[1]), minute: Number(match[2]) };
5036
- };
5037
-
5038
- // Half a time is not a time: a control holding one of the two and nothing in
5039
- // the other has no value at all, and a form has nothing to send about it.
5040
- const formatTimeParts = (hour, minute) => {
5041
- if (
5042
- hour === "" ||
5043
- hour === undefined ||
5044
- minute === "" ||
5045
- minute === undefined
5046
- ) {
5047
- return undefined;
5048
- }
5049
- return `${padTwo$1(hour)}:${padTwo$1(minute)}`;
5050
- };
5051
-
5052
- const minutesFromTime$1 = (time) => {
5053
- const parts = parseTimeParts(time);
5054
- if (!parts) {
5055
- return null;
5056
- }
5057
- return parts.hour * 60 + parts.minute;
5058
- };
5059
-
5060
- // Not folded back into the day: 1440 is "24:00", the end of the day a span can
5061
- // run into.
5062
- const timeFromMinutes = (minutes) => {
5063
- return `${padTwo$1(Math.floor(minutes / 60))}:${padTwo$1(minutes % 60)}`;
5064
- };
5065
-
5066
- const padTwo$1 = (value) => String(value).padStart(2, "0");
5067
-
5068
- // What a bound signal's type says is what its VALUE is (a validity type);
5069
- // what a control's `type` says is what the control IS. This table is the whole
5070
- // bridge between the two vocabularies: a value type with a row here asks for
5071
- // that control, one without a row asks for nothing and the control keeps its
5072
- // own default. "string" has no row on purpose — it is the value every typeless
5073
- // control already holds — and a name only one side knows must never cross as
5074
- // is: a control type nobody implements is a plain text field that writes an
5075
- // object as "[object Object]" (see warnOnUnknownPickerType in picker.jsx).
5076
- // Numeric value types map to navi_number rather than the native type="number"
5077
- // (spinner buttons, poor UX).
5078
- const CONTROL_TYPE_BY_SIGNAL_TYPE = {
5079
- boolean: "checkbox",
5080
- number: "navi_number",
5081
- integer: "navi_number",
5082
- float: "navi_number",
5083
- ratio: "navi_number",
5084
- longitude: "navi_number",
5085
- latitude: "navi_number",
5086
- second: "navi_number",
5087
- minute: "navi_number",
5088
- hour: "navi_number",
5089
- year: "navi_number",
5090
- percentage: "navi_percentage",
5091
- date: "date",
5092
- month: "month",
5093
- week: "week",
5094
- time: "time",
5095
- datetime: "datetime",
5096
- duration: "duration",
5097
- color: "color",
5098
- email: "email",
5099
- url: "url",
5100
- array: "array",
5101
- object: "object",
5102
- };
5103
-
5104
- // Conceptual navi types: defaults, plus the host type they resolve to.
5105
- // `navi-input-type` keeps the type the caller asked for once the host has
5106
- // become something plainer — it is what says the value is a number (see
5107
- // isNumberInput), and what lets constraint messages use domain-specific
5108
- // wording instead of the generic "Ce nombre doit être...".
5109
- // A type a picker preset draws (navi_time, navi_minute: see picker_preset.jsx)
5110
- // has no row: resolveInputProps runs before the preset resolver, and a row
5111
- // would turn the type into its host before the preset resolver reads it. The
5112
- // preset hands the host type on itself.
5113
- const NAVI_TYPE_DEFAULTS = {
5114
- navi_percentage: {
5115
- "type": "navi_number",
5116
- "navi-input-type": "percentage",
5117
- "min": 0,
5118
- "max": 100,
5119
- "step": 1,
5120
- },
5121
- navi_number: {
5122
- "type": "text",
5123
- "navi-input-type": "number",
5124
- "autoCorrect": "off",
5125
- "spellcheck": false,
5126
- "autoComplete": "off",
5127
- },
5128
- };
5129
-
5130
- // The navi input types whose value IS a number.
5131
- const NUMBER_NAVI_INPUT_TYPE_SET = new Set([
5132
- "number",
5133
- "percentage",
5134
- "hour",
5135
- "minute",
5136
- "second",
5137
- ]);
5138
-
5139
- /**
5140
- * Whether the control holds a number — asked of what the control IS, never of
5141
- * `inputMode`. In HTML `inputmode` picks the on-screen keyboard and says
5142
- * nothing about the value: a licence number, a postal code or a card number
5143
- * with a check letter all want the digit keypad while staying strings. What a
5144
- * number field is spelled `type="number"`, or a navi type that resolves to a
5145
- * text host and leaves `navi-input-type` behind to say what it was.
5146
- */
5147
- const isNumberInput = (type, naviInputType) => {
5148
- if (type === "number") {
5149
- return true;
5150
- }
5151
- return NUMBER_NAVI_INPUT_TYPE_SET.has(naviInputType);
5152
- };
5153
-
5154
- /**
5155
- * resolveInputProps — normalizes input-related props that are shared across
5156
- * `<Picker>`, `<Input>` (textual) and `<Range>`. Mutates the props object in place.
5157
- *
5158
- * Normalization is applied recursively: a navi type may resolve to another navi
5159
- * type (e.g. `navi_percentage` → `navi_number` → `text`), and each step applies its
5160
- * own formatters and defaults before moving to the next.
5161
- *
5162
- * Steps applied for each type:
5163
- * 1. Record the original navi type in `props["navi-input-type"]` (first call only).
5164
- * 2. Apply defaults for the current type (min, max, step, and any other props),
5165
- * only when the prop is not already set.
5166
- * 3. Apply min/max formatters (e.g. HH:MM string → number for duration types,
5167
- * Date → formatted string for date/time types).
5168
- * 4. Apply step formatter (same conversion rules).
5169
- * 5. Remap `props.type` to the target type defined by the current type's defaults,
5170
- * then recurse.
5171
- *
5172
- * Supported navi types and their targets:
5173
- * - `navi_percentage` → `navi_number` (0–100, step 1)
5174
- * - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
5175
- *
5176
- * Standard HTML input types with formatters:
5177
- * - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
5178
- * min/max accept `Date` instances or timestamps and are converted to the
5179
- * string format expected by the native input.
5180
- * - `time`, `datetime-local`, `datetime`:
5181
- * step accepts `"HH:MM"` and is converted to seconds.
5182
- */
5183
- /**
5184
- * A bound signal that carries a default of its own says the same thing on every
5185
- * control: the control starts there, uncontrolled, and a reset goes back to it.
5186
- * Uncontrolled here is about where the control starts, not about whether it
5187
- * follows the signal — the binding stays two-way either way (see
5188
- * stateFromSignal in control_hooks.jsx) — nor about what a form counts as
5189
- * held: that is the signal's content (isUIStateHeld in held_ui_state.js), and a
5190
- * stateSignal holds its default. Written once and used by everything
5191
- * that takes a `signal`, so one signal cannot mean two different things
5192
- * depending on which control it was handed to.
5193
- */
5194
- const seedDefaultValueFromSignal = (props) => {
5195
- const signalOptions = props.signal?.options;
5196
- if (!signalOptions) {
5197
- return;
5198
- }
5199
- if (Object.hasOwn(props, "defaultValue")) {
5200
- // explicit defaultValue prop prevails
5201
- return;
5202
- }
5203
- // Snapshot the signal's current default so that resetUIState restores to the
5204
- // original default — not the value the signal had at the time of the last
5205
- // re-render.
5206
- const defaultValue = signalOptions.getDefaultValue(false);
5207
- if (defaultValue !== undefined) {
5208
- props.defaultValue = defaultValue;
5209
- }
5210
- };
5211
-
5212
- const resolveInputProps = (props, { controlType = "input" } = {}) => {
5213
- // `signal` carries a bound state signal. It is left on `props` on purpose:
5214
- // `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
5215
- // follow it, and `onUIAction` (ui_state_controller.js) writes user
5216
- // interactions back into it. Here we only derive input defaults (type/min/max,
5217
- // defaultValue/defaultChecked) from the signal's `options`, so the control
5218
- // ends up uncontrolled-with-default while still bound to the signal.
5219
- const signal = props.signal;
5220
- if (signal) {
5221
- const signalOptions = signal.options;
5222
- if (signalOptions) {
5223
- for (const key of ["min", "max", "step"]) {
5224
- if (props[key] === undefined && signalOptions[key] !== undefined) {
5225
- props[key] = signalOptions[key];
5226
- }
5227
- }
5228
- if (props.type === undefined && signalOptions.type !== undefined) {
5229
- const typeFromSignal = CONTROL_TYPE_BY_SIGNAL_TYPE[signalOptions.type];
5230
- // A value type and the control for it usually agree — a date-typed
5231
- // signal wants a date field — but a boolean one maps to a checkbox, and
5232
- // a picker made into a checkbox is not a picker with a different look:
5233
- // it is another control, with no popup to open. A picker asked to hold
5234
- // a yes/no keeps its two rows and stays itself.
5235
- const wouldChangeWhatTheControlIs =
5236
- controlType === "picker" &&
5237
- (typeFromSignal === "checkbox" || typeFromSignal === "radio");
5238
- if (typeFromSignal !== undefined && !wouldChangeWhatTheControlIs) {
5239
- props.type = typeFromSignal;
5240
- }
5241
- }
5242
- }
5243
-
5244
- const isCheckable = props.type === "checkbox" || props.type === "radio";
5245
- if (isCheckable) {
5246
- if (Object.hasOwn(props, "defaultChecked")) ; else {
5247
- // If no explicit defaultChecked, derive it from the signal's default
5248
- // value so that resetUIState restores to the original default.
5249
- // Only a stateSignal carries a default of its own; a plain signal has
5250
- // no `options` at all, hence the optional read.
5251
- const defaultVal = signalOptions?.getDefaultValue(false);
5252
- if (defaultVal === undefined) ; else if (props.type === "radio") {
5253
- if (defaultVal === true) {
5254
- props.defaultChecked = true;
5255
- } else if (
5256
- Object.hasOwn(props, "value") &&
5257
- defaultVal === props.value
5258
- ) {
5259
- props.defaultChecked = true;
5260
- }
5261
- } else if (typeof defaultVal === "boolean") {
5262
- // Standalone checkbox bound to a boolean signal.
5263
- props.defaultChecked = defaultVal;
5264
- } else {
5265
- // Checkbox is a group member: defaultVal is the array of
5266
- // selected item values.
5267
- const checkboxValue = props.value;
5268
- props.defaultChecked =
5269
- Array.isArray(defaultVal) && defaultVal.includes(checkboxValue);
5270
- }
5271
- }
5272
- return;
5273
- }
5274
-
5275
- seedDefaultValueFromSignal(props);
5276
- }
5277
-
5278
- const currentType = props.type;
5279
- // Apply min/max/step formatters before anything else — this must run even for
5280
- // standard HTML types (date, time, etc.) that have no NAVI_TYPE_DEFAULTS entry.
5281
- const currentTypeMinMaxFormatter = MIN_MAX_FORMATTER_BY_TYPE[currentType];
5282
- const currentTypeStepFormatter = STEP_FORMATTER_BY_TYPE[currentType];
5283
- if (currentTypeMinMaxFormatter) {
5284
- props.min = currentTypeMinMaxFormatter(props.min);
5285
- props.max = currentTypeMinMaxFormatter(props.max);
5286
- }
5287
- if (currentTypeStepFormatter) {
5288
- props.step = currentTypeStepFormatter(props.step);
5289
- }
5290
-
5291
- // For navi_number: choose inputMode based on whether step/min/max suggest decimals.
5292
- // inputMode="numeric" (integer keyboard) vs "decimal" (keyboard with decimal separator).
5293
- if (currentType === "navi_number") {
5294
- if (props.inputMode === undefined) {
5295
- props.inputMode =
5296
- hasDecimalPlaces(props.step) ||
5297
- hasDecimalPlaces(props.min) ||
5298
- hasDecimalPlaces(props.max)
5299
- ? "decimal"
5300
- : "numeric";
5301
- }
5302
- }
5303
-
5304
- const { charGuard } = props;
5305
- if (charGuard) {
5306
- if (charGuard === true || charGuard === "auto") {
5307
- // Auto-resolve charGuard from context.
5308
- let charGuardResolved;
5309
- const inputMode = props.inputMode;
5310
- if (inputMode === "numeric") {
5311
- charGuardResolved = "numeric";
5312
- } else if (inputMode === "decimal") {
5313
- charGuardResolved = "decimal";
5314
- } else if (currentType === "tel") {
5315
- charGuardResolved = "tel";
5316
- } else if (currentType === "email") {
5317
- charGuardResolved = "email";
5318
- }
5319
- if (charGuardResolved !== undefined) {
5320
- props.charGuard = charGuardResolved;
5321
- }
5322
- }
5323
- // charGuard is now resolved: derive inputMode from it if not already set.
5324
- if (props.inputMode === undefined && props.charGuard) {
5325
- const autoMode = INPUT_MODE_FROM_CHAR_GUARD[props.charGuard];
5326
- if (autoMode) {
5327
- props.inputMode = autoMode;
5328
- }
5329
- }
5330
- // Build pattern from the resolved charGuard (preset name → class, or raw class passthrough).
5331
- if (props.pattern === undefined && props.charGuard) {
5332
- const charClass = CHAR_CLASS_PRESETS[props.charGuard] ?? props.charGuard;
5333
- props.pattern = `${charClass}*`;
5334
- }
5335
- }
5336
-
5337
- // Compute maxLength from max when inputMode is numeric/decimal.
5338
- // Done here (after inputMode is set) so controller.props has the resolved value.
5339
- if (props.maxLength === undefined) {
5340
- if (props.inputMode === "numeric") {
5341
- const { min, max } = props;
5342
- if (max === undefined) ; else {
5343
- const canBeNegative = min === undefined ? max < 0 : min < 0;
5344
- const signCharCount = canBeNegative ? 1 : 0;
5345
- const integerDigitCount = String(Math.floor(Math.abs(max))).length;
5346
- props.maxLength = signCharCount + integerDigitCount;
5347
- }
5348
- } else if (props.inputMode === "decimal") {
5349
- const { min, max, step } = props;
5350
- if (max === undefined) ; else if (step === undefined) ; else {
5351
- const canBeNegative = min === undefined ? max < 0 : min < 0;
5352
- const signCharCount = canBeNegative ? 1 : 0;
5353
- const integerDigitCount = String(Math.floor(Math.abs(max))).length;
5354
- const stepStr = String(step);
5355
- const dotIndex = stepStr.indexOf(".");
5356
- // integer step + decimal inputMode is an unusual combo, but we stay consistent:
5357
- // no decimal part in maxLength since valid values are whole numbers anyway
5358
- const isIntegerStep = dotIndex === -1;
5359
- const decimalSignCharCount = isIntegerStep ? 0 : 1;
5360
- const decimalDigitCount = isIntegerStep
5361
- ? 0
5362
- : stepStr.length - dotIndex - 1;
5363
- props.maxLength =
5364
- signCharCount +
5365
- integerDigitCount +
5366
- decimalSignCharCount +
5367
- decimalDigitCount;
5368
- }
5369
- }
5370
- }
5371
-
5372
- // Resolve maxLengthGuard boolean/auto → the computed maxLength number.
5373
- if (props.maxLengthGuard === true || props.maxLengthGuard === "auto") {
5374
- props.maxLengthGuard =
5375
- typeof props.maxLength === "number" ? props.maxLength : undefined;
5376
- }
5377
-
5378
- const currentTypeDefaults = NAVI_TYPE_DEFAULTS[currentType];
5379
- if (!currentTypeDefaults) {
5380
- return;
5381
- }
5382
-
5383
- for (const key of Object.keys(currentTypeDefaults)) {
5384
- if (props[key] === undefined) {
5385
- props[key] = currentTypeDefaults[key];
5386
- }
5387
- }
5388
- const targetType = currentTypeDefaults.type;
5389
- props.type = targetType;
5390
- resolveInputProps(props);
5391
- };
5392
-
5393
- // Presets that imply a specific mobile keyboard inputMode.
5394
- const INPUT_MODE_FROM_CHAR_GUARD = {
5395
- numeric: "numeric",
5396
- pin: "numeric",
5397
- card: "numeric",
5398
- tel: "tel",
5399
- decimal: "decimal",
5400
- };
5401
-
5402
- const normalizeToDate = (value) => {
5403
- if (value === undefined || value === null) {
5404
- return null;
5405
- }
5406
- if (typeof value === "number") {
5407
- return new Date(value);
5408
- }
5409
- if (value instanceof Date) {
5410
- return value;
5411
- }
5412
- return null;
5413
- };
5414
-
5415
- const toInputDate = (value) => {
5416
- const date = normalizeToDate(value);
5417
- if (!date) {
5418
- return value;
5419
- }
5420
- const yyyy = date.getFullYear();
5421
- const mm = String(date.getMonth() + 1).padStart(2, "0");
5422
- const dd = String(date.getDate()).padStart(2, "0");
5423
- return `${yyyy}-${mm}-${dd}`;
5424
- };
5425
- const toInputMonth = (value) => {
5426
- const date = normalizeToDate(value);
5427
- if (!date) {
5428
- return value;
5429
- }
5430
- const yyyy = date.getFullYear();
5431
- const mm = String(date.getMonth() + 1).padStart(2, "0");
5432
- return `${yyyy}-${mm}`;
5433
- };
5434
- const toInputWeek = (value) => {
5435
- const date = normalizeToDate(value);
5436
- if (!date) {
5437
- return value;
5438
- }
5439
- // ISO week number
5440
- const d = new Date(date);
5441
- d.setHours(0, 0, 0, 0);
5442
- d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
5443
- const yearStart = new Date(d.getFullYear(), 0, 4);
5444
- const week =
5445
- Math.round(
5446
- ((d - yearStart) / 86400000 - 3 + ((yearStart.getDay() + 6) % 7)) / 7,
5447
- ) + 1;
5448
- return `${d.getFullYear()}-W${String(week).padStart(2, "0")}`;
5449
- };
5450
- const toInputTime = (value) => {
5451
- const date = normalizeToDate(value);
5452
- if (!date) {
5453
- return value;
5454
- }
5455
- const hh = String(date.getHours()).padStart(2, "0");
5456
- const mm = String(date.getMinutes()).padStart(2, "0");
5457
- return `${hh}:${mm}`;
5458
- };
5459
- const toInputDatetime = (value) => {
5460
- const date = normalizeToDate(value);
5461
- if (!date) {
5462
- return value;
5463
- }
5464
- const yyyy = date.getFullYear();
5465
- const mm = String(date.getMonth() + 1).padStart(2, "0");
5466
- const dd = String(date.getDate()).padStart(2, "0");
5467
- const hh = String(date.getHours()).padStart(2, "0");
5468
- const min = String(date.getMinutes()).padStart(2, "0");
5469
- return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
5470
- };
5471
-
5472
- const MIN_MAX_FORMATTER_BY_TYPE = {
5473
- "date": toInputDate,
5474
- "month": toInputMonth,
5475
- "week": toInputWeek,
5476
- "time": toInputTime,
5477
- "datetime-local": toInputDatetime,
5478
- "datetime": toInputDatetime,
5479
- };
5480
- const STEP_FORMATTER_BY_TYPE = {
5481
- "time": timeStringToSeconds,
5482
- "datetime-local": timeStringToSeconds,
5483
- "datetime": timeStringToSeconds,
5484
- };
5485
-
5486
- const hasDecimalPlaces = (value) => {
5487
- if (value === undefined || value === null) {
5488
- return false;
5489
- }
5490
- const num = Number(value);
5491
- return !isNaN(num) && !Number.isInteger(num);
5492
- };
5493
-
5494
- const dispatchRequestSetUIState = (element, value, detail) => {
5495
- const controlHost = findControlHost(element) || element;
5496
- return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
5497
- ...detail,
5498
- value,
5499
- });
5500
- };
5501
- const dispatchRequestClearUIState = (element, e) => {
5502
- const controlHost = findControlHost(element) || element;
5503
- return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
5504
- event: e,
5505
- });
5506
- };
5507
- const dispatchRequestResetUIState = (element, e) => {
5508
- const controlHost = findControlHost(element) || element;
5509
- return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
5510
- event: e,
5511
- });
5512
- };
5513
- /**
5514
- * @param {Element} el
5515
- * @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
5516
- * Only a button ever answers differently — one with no value of its own
5517
- * inherits the value of the control around it, which is what makes
5518
- * `--navi-send` on a form's button be about that form. Something asking what
5519
- * THIS element says (a travel command reading what the travel is about) wants
5520
- * the own value and would otherwise be handed the surrounding control's.
5521
- */
5522
- const getUIStateFromElement = (el, { own } = {}) => {
5523
- let uiState;
5524
- dispatchInternalCustomEvent(el, "navi_get_ui_state", {
5525
- own,
5526
- respondWith: (v) => {
5527
- uiState = v;
5528
- },
5529
- });
5530
- return uiState;
5531
- };
5532
-
5533
- /**
5534
- * Converts a JS value into the form expected by the browser DOM property for a
5535
- * given control type/input type combination.
5536
- *
5537
- * For example:
5538
- * - `datetime-local` inputs expect a local datetime string without timezone
5539
- * - `number`/`range` inputs expect a numeric string or number
5540
- * - `color` inputs require a non-empty hex string (falls back to `#000000`)
5541
- * - All other inputs receive the value as-is (undefined → "")
5542
- *
5543
- * Returns either the converted value directly, or a converter function when the
5544
- * conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
5545
- *
5546
- * @param {any} value - The JS value to convert.
5547
- * @param {{ controlType: string, type: string }} options
5548
- * @returns {any} The DOM-compatible value or a converter function.
5549
- */
5550
- const asControlHostValue = (
5551
- jsValue,
5552
- { controlType, type, naviInputType, pad },
5553
- ) => {
5554
- if (controlType === "select") {
5555
- // A select holds one of its options, always a string; holding nothing is
5556
- // the empty option, which the element spells "".
5557
- return asInputValue(jsValue);
5558
- }
5559
- if (controlType === "input" || controlType === "picker") {
5560
- if (type === "datetime-local") {
5561
- return asDatetimeLocalString(jsValue);
5562
- }
5563
- if (type === "range" || isNumberInput(type, naviInputType)) {
5564
- return asNumberString(jsValue, pad);
5565
- }
5566
- if (type === "color") {
5567
- return asColorString(jsValue);
5568
- }
5569
- return asInputValue(jsValue);
5570
- }
5571
- return jsValue;
5572
- };
5573
- // As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
5574
- // datetime-local does not support timezones
5575
- const asDatetimeLocalString = (dateTimeString) => {
5576
- const date = new Date(dateTimeString);
5577
- if (isNaN(date.getTime())) {
5578
- return dateTimeString;
5579
- }
5580
- const year = date.getFullYear();
5581
- const month = String(date.getMonth() + 1).padStart(2, "0");
5582
- const day = String(date.getDate()).padStart(2, "0");
5583
- const hours = String(date.getHours()).padStart(2, "0");
5584
- const minutes = String(date.getMinutes()).padStart(2, "0");
5585
- const seconds = String(date.getSeconds()).padStart(2, "0");
5586
- return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
5587
- };
5588
- // `pad` is how many digits the number is WRITTEN on — an hour is held as 7 and
5589
- // shown as "07". Held and shown are two things here, the way they are for a
5590
- // datetime-local above: what the field says is derived from what the control
5591
- // holds, and reading it back (readNumberFromInput) gives the number again.
5592
- const asNumberString = (jsValue, pad) => {
5593
- if (jsValue === undefined) {
5594
- return "";
5595
- }
5596
- if (!pad || jsValue === "" || jsValue === null) {
5597
- return jsValue;
5598
- }
5599
- const number = Number(jsValue);
5600
- if (Number.isNaN(number)) {
5601
- return jsValue;
5602
- }
5603
- const negative = number < 0;
5604
- const digits = String(negative ? -number : number).padStart(Number(pad), "0");
5605
- return negative ? `-${digits}` : digits;
5606
- };
5607
- // Browser requires a non-empty value for <input type="color">.
5608
- // When our logical value is empty we give it #000000 so it doesn't choke.
5609
- // The UI uses the original (possibly empty) value to show the checkerboard.
5610
- const asColorString = (jsValue) => {
5611
- return jsValue || "#000000";
5612
- };
5613
- const asInputValue = (jsValue) => {
5614
- if (jsValue === undefined) {
5615
- return "";
5616
- }
5617
- return jsValue;
5618
- };
5619
-
5620
- /**
5621
- * Reads the current logical JS value from a control host DOM element.
5622
- *
5623
- * Handles all navi control host element types:
5624
- * - `<button>` — reads via `navi_get_value` custom event, falls back to `button.value`
5625
- * - `<input type="number|range">` — parses as a number, returns `undefined` when empty
5626
- * - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
5627
- * via `navi_get_value` custom event (to preserve the original JS type of the value prop)
5628
- * - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
5629
- * - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
5630
- * - All other inputs — returns `input.value` as a string
5631
- *
5632
- * @param {HTMLElement} controlHost - The control host DOM element to read from.
5633
- * @returns {any} The current logical value of the control.
5634
- */
5635
- const readControlValue = (controlHost) => {
5636
- if (
5637
- controlHost.tagName === "BUTTON" ||
5638
- controlHost.getAttribute("role") === "button"
5639
- ) {
5640
- return readValueFromButton(controlHost);
5641
- }
5642
- if (controlHost.tagName === "INPUT") {
5643
- // important: input.type = "navi_js"; followed by input.type; returns "text"
5644
- // so use getAttribute
5645
- const type = controlHost.getAttribute("type");
5646
-
5647
- if (
5648
- type === "range" ||
5649
- isNumberInput(type, controlHost.getAttribute("navi-input-type"))
5650
- ) {
5651
- return readNumberFromInput(controlHost);
5652
- }
5653
- if (type === "color") {
5654
- return readValueFromControlHost(controlHost);
5655
- }
5656
- if (type === "checkbox" || type === "radio") {
5657
- return readValueFromCheckableInput(controlHost);
5658
- }
5659
- if (type === "datetime-local") {
5660
- return readDatetimeLocalFromInput(controlHost);
5661
- }
5662
- if (type === "navi_js") {
5663
- return getUIStateFromElement(controlHost);
5664
- }
5665
- return readValueFromInput(controlHost);
5666
- }
5667
- if (controlHost.hasAttribute("navi-control-host")) {
5668
- // Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
5669
- return readValueFromControlHost(controlHost);
5670
- }
5671
- return readValueFromElement(controlHost);
5672
- };
5673
- const readValueFromControlHost = (controlHost) => {
5674
- return readValueFromNaviCustomEvent(controlHost, controlHost.value);
5675
- };
5676
- const readValueFromButton = (button) => {
5677
- return readValueFromControlHost(button);
5678
- };
5679
- const readDatetimeLocalFromInput = (input) => {
5680
- const localDateTimeString = input.value;
5681
- if (localDateTimeString === "") {
5682
- return "";
5683
- }
5684
- const localDate = new Date(localDateTimeString);
5685
- if (isNaN(localDate.getTime())) {
5686
- return localDateTimeString;
5687
- }
5688
- return localDate.toISOString();
5689
- };
5690
- const readNumberFromInput = (input) => {
5691
- const numberString = input.value;
5692
- if (numberString === "") {
5693
- return "";
5694
- }
5695
- const asNumber = Number(numberString);
5696
- if (isNaN(asNumber)) {
5697
- return numberString;
5698
- }
5699
- return asNumber;
5700
- };
5701
- const readValueFromCheckableInput = (input) => {
5702
- const checked = input.checked;
5703
- if (!checked) {
5704
- return undefined;
5705
- }
5706
- return readValueFromControlHost(input);
5707
- };
5708
- const readValueFromInput = (input) => {
5709
- const value = input.value;
5710
- return value;
5711
- };
5712
- const readValueFromElement = (element) => {
5713
- const value = element.value;
5714
- return value;
5715
- };
5716
- const readValueFromNaviCustomEvent = (field, fallback) => {
5717
- // prefer the value given as prop (respect original type, browser would convert to string)
5718
- let responded;
5719
- let value;
5720
- dispatchCustomEvent(field, "navi_get_value", {
5721
- respondWith: (jsValue) => {
5722
- responded = true;
5723
- value = jsValue;
5724
- },
5725
- });
5726
- if (responded) {
5727
- return value;
5728
- }
5729
- return fallback;
5730
- };
5731
-
5732
- // In-memory registry of all mounted ui state controllers keyed by their id.
5733
- // Allows direct controller access without dispatching DOM events — used by external
5734
- // callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
5735
- const controllersById = new Map();
5736
-
5737
- // In-memory registry for radio controllers, keyed by input name.
5738
- // Allows radio sibling unchecking without querying the DOM — necessary when
5739
- // items are virtualized and their DOM element may not exist at the time.
5740
- // Form scoping is reproduced by comparing parentUIStateController references.
5741
- const radioControllersByName = new Map();
5742
-
5743
- // Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
5744
- // When a value is an object/array, we store it here and write a reference string to the DOM
5745
- // instead of "[object Object]". Console-inspectable via window.__navi_js('id').
5746
- // The controller id is used as key — if the controller has no id, the value is not registered.
5747
- const naviJsRegistry = new Map();
5748
-
5749
- const getUIStateControllerById = (id) => controllersById.get(id);
5750
- const getRadioSiblings = (radioUIStateController) => {
5751
- const siblings = radioControllersByName.get(radioUIStateController.name);
5752
- return siblings;
5753
- };
5754
-
5755
- const toDomValue = (
5756
- jsValue,
5757
- { controlType, id, type, naviInputType, pad },
5758
- ) => {
5759
- const domValue = asControlHostValue(jsValue, {
5760
- controlType,
5761
- type,
5762
- naviInputType,
5763
- pad,
5764
- });
5765
- if (isSerializableAsDomValue(domValue)) {
5766
- return domValue;
5767
- }
5768
- naviJsRegistry.set(id, domValue);
5769
- return `window.__navi_js('${id}')`;
5770
- };
5771
-
5772
- window.__navi_js = (id) => naviJsRegistry.get(id);
5773
- const isSerializableAsDomValue = (value) => {
5774
- if (value === null || value === undefined) {
5775
- return true;
5776
- }
5777
- const type = typeof value;
5778
- return type === "string" || type === "number" || type === "boolean";
5779
- };
5780
-
5781
- const onUIStateControllerCreated = (uiStateController) => {
5782
- const { id, name, controlType } = uiStateController;
5783
- if (id) {
5784
- controllersById.set(id, uiStateController);
5785
- }
5786
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
5787
- if (proxyFor) {
5788
- let proxySet = proxyControllersByRealInputId.get(proxyFor);
5789
- if (!proxySet) {
5790
- proxySet = new Set();
5791
- proxyControllersByRealInputId.set(proxyFor, proxySet);
5792
- }
5793
- proxySet.add(uiStateController);
5794
- }
5795
- if (
5796
- controlType === "input" &&
5797
- uiStateController.props.type === "radio" &&
5798
- name
5799
- ) {
5800
- let set = radioControllersByName.get(name);
5801
- if (!set) {
5802
- set = new Set();
5803
- radioControllersByName.set(name, set);
5804
- }
5805
- set.add(uiStateController);
5806
- }
5807
- };
5808
- const onUIStateControllerDestroyed = (uiStateController) => {
5809
- const { id, name, controlType } = uiStateController;
5810
- if (id) {
5811
- // Only the controller the id currently points at may take the entry away:
5812
- // when two controls share an id, the one leaving would otherwise unregister
5813
- // the one staying (see the warning in onUIStateControllerCreated), and the
5814
- // same holds while a control is being replaced by its successor, which
5815
- // registers during its render, before this cleanup runs.
5816
- if (controllersById.get(id) === uiStateController) {
5817
- controllersById.delete(id);
5818
- naviJsRegistry.delete(id);
5819
- }
5820
- }
5821
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
5822
- if (proxyFor) {
5823
- const proxySet = proxyControllersByRealInputId.get(proxyFor);
5824
- if (proxySet) {
5825
- proxySet.delete(uiStateController);
5826
- if (proxySet.size === 0) {
5827
- proxyControllersByRealInputId.delete(proxyFor);
5828
- }
5829
- }
5830
- }
5831
- if (
5832
- controlType === "input" &&
5833
- uiStateController.controlHostProps.type === "radio" &&
5834
- name
5835
- ) {
5836
- const set = radioControllersByName.get(name);
5837
- if (set) {
5838
- set.delete(uiStateController);
5839
- if (set.size === 0) {
5840
- radioControllersByName.delete(name);
5841
- }
5842
- }
5843
- }
5844
- };
5845
-
5846
- /**
5847
- * Controller-based equivalent of findControlProxyTarget.
5848
- * Given a proxy controller, returns the real control's controller.
5849
- * Finds the target by walking the parent controller's children — no DOM queries.
5850
- * Returns `null` when the controller is not a proxy or the target is not found.
5851
- */
5852
- const findControlProxyTargetController = (controller) => {
5853
- const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
5854
- if (!proxyFor) {
5855
- return null;
5856
- }
5857
- return getUIStateControllerById(proxyFor) ?? null;
5858
- };
5859
-
5860
- // Reverse-lookup map: real-input id → the proxy controllers that reference it
5861
- // via `navi-control-proxy-for`. A single control can be represented by several
5862
- // proxies (an "enable"/"disable" button pair for one radio, for instance), so
5863
- // each id holds a set. Maintained on create/destroy so lookup is O(1).
5864
- const proxyControllersByRealInputId = new Map();
5865
- const findProxyControllers = (realInputId) => {
5866
- if (!realInputId) {
5867
- return null;
5868
- }
5869
- return proxyControllersByRealInputId.get(realInputId) ?? null;
5870
- };
5871
-
5872
4739
  /**
5873
4740
  * The attributes constraints read, filled by each constraint module as it
5874
4741
  * evaluates. A constraint declares the attribute it wants (`"data-no-emoji"`)
@@ -6497,113 +5364,6 @@ const isInertOnClick = (element) => {
6497
5364
  return true;
6498
5365
  };
6499
5366
 
6500
- /**
6501
- * DOM utilities for the proxy control pattern.
6502
- *
6503
- * Some components need a native `<input>` internally — for form submission,
6504
- * constraint validation, or browser autofill — but the user may not want to
6505
- * display that input at all. In those cases the input is hidden and a separate
6506
- * visible element (the proxy) takes over the visual and interactive role.
6507
- *
6508
- * The typical use case is `SelectableList`: each list item acts as a styled
6509
- * radio button, but an actual `<input type="radio">` lives hidden in the DOM
6510
- * so form submission and validation work natively. When users DO want to
6511
- * display the input they want full control over its appearance, so they render
6512
- * their own element and link it to the real input via `navi-control-proxy-for`:
6513
- *
6514
- * ```html
6515
- * <div>
6516
- * <input id="color_red" type="radio" name="color" value="red" /> ← real control (hidden, drives form/validation)
6517
- * <input type="radio" name="proxy" value="red" /> ← proxy (visible, delegates interactions to real input)
6518
- * </div>
6519
- * ```
6520
- *
6521
- * A press on a checkable proxy is dealt to the real control, and so is the
6522
- * focus: state management, validation and form submission then all happen
6523
- * where the value lives. A proxy that says what its press does — a button
6524
- * carrying `command="--navi-check"` — answers the press itself instead.
6525
- *
6526
- * Note: an alternative design would be to require users to always instantiate
6527
- * the input explicitly — e.g. `<Selectable.Input headless />` when they don't
6528
- * want to display it. That would remove the need for the proxy mechanism
6529
- * entirely. For now we keep the proxy pattern.
6530
- */
6531
-
6532
-
6533
- /**
6534
- * Given a proxy element, returns the real control it represents.
6535
- * Returns `null` when `el` is not a proxy.
6536
- */
6537
- const findControlProxyTarget = (el) => {
6538
- const proxyFor = el.getAttribute("navi-control-proxy-for");
6539
- if (!proxyFor) {
6540
- return null;
6541
- }
6542
- return document.getElementById(proxyFor);
6543
- };
6544
-
6545
- // Shared: asked on every pseudo state change of every box, and almost always
6546
- // empty.
6547
- const NO_PROXIES = Object.freeze([]);
6548
- /**
6549
- * Given a real control element, returns every proxy that visually represents
6550
- * it — a control can have more than one (an "enable"/"disable" button pair for
6551
- * one radio, for instance).
6552
- *
6553
- * Answered from the controller registry rather than the document: every proxy
6554
- * declares itself through the `navi-control-proxy-for` prop, so the registry
6555
- * knows them all, while asking the document means walking it in full for each
6556
- * of the (overwhelmingly many) controls that have no proxy at all.
6557
- *
6558
- * Returns an empty array when no proxy exists for `el`.
6559
- */
6560
- const findControlProxies = (el) => {
6561
- if (!el.id) {
6562
- return NO_PROXIES;
6563
- }
6564
- const proxyControllerSet = findProxyControllers(el.id);
6565
- if (!proxyControllerSet) {
6566
- return NO_PROXIES;
6567
- }
6568
- const proxyElements = [];
6569
- for (const proxyController of proxyControllerSet) {
6570
- const proxyElement = proxyController.ref.current;
6571
- if (proxyElement) {
6572
- proxyElements.push(proxyElement);
6573
- }
6574
- }
6575
- return proxyElements;
6576
- };
6577
-
6578
- /**
6579
- * Given a real control element, returns the proxy a gesture on the control
6580
- * lands on — the one thing said about the control is drawn on.
6581
- *
6582
- * Use when you need a single visible stand-in for the real control — anchoring
6583
- * a callout, for instance. Anything notifying proxies of a state change wants
6584
- * `findControlProxies` instead, so a control represented by several of them
6585
- * updates all of them.
6586
- *
6587
- * A proxy the pointer cannot reach is drawn for the control without standing
6588
- * for it: the surface around it takes the presses for everything it holds, so
6589
- * that surface is where a refusal about the control belongs — not a box inside
6590
- * it the finger never has to find. A selectable list row whose whole area is
6591
- * the click target is exactly that arrangement (see `selectableArea`), and it
6592
- * is read from the arrangement itself rather than from a flag repeating it:
6593
- * where a gesture can land IS pointer-events.
6594
- *
6595
- * Returns `null` when no proxy stands for `el`.
6596
- */
6597
- const findControlProxy = (el) => {
6598
- for (const proxyElement of findControlProxies(el)) {
6599
- if (getComputedStyle(proxyElement).pointerEvents === "none") {
6600
- continue;
6601
- }
6602
- return proxyElement;
6603
- }
6604
- return null;
6605
- };
6606
-
6607
5367
  /**
6608
5368
  * Typography reset shared by the surfaces a control opens: callout, popover,
6609
5369
  * dialog.
@@ -20651,1453 +19411,6 @@ const getDefaultDisplay = (tagName) => {
20651
19411
  return TAG_NAME_TO_DEFAULT_DISPLAY.get(normalizedTagName) || "inline";
20652
19412
  };
20653
19413
 
20654
- const addInputEffect = (
20655
- input,
20656
- callback,
20657
- { waitForChange = false, debounce = 0, debugInteraction = () => {} } = {},
20658
- ) => {
20659
- const getState =
20660
- input.type === "checkbox" || input.type === "radio"
20661
- ? () => input.checked
20662
- : () => input.value;
20663
-
20664
- if (waitForChange) {
20665
- return listenInputStateChange(input, callback, { getState });
20666
- }
20667
-
20668
- const [teardown, addTeardown] = createPubSub();
20669
- let currentState = getState();
20670
- let timeout;
20671
- let debounceTimeout;
20672
- let onEvent;
20673
- if (debounce) {
20674
- onEvent = (e, { skipDebounce } = {}) => {
20675
- clearTimeout(timeout);
20676
- clearTimeout(debounceTimeout);
20677
- const state = getState();
20678
- if (state === currentState) {
20679
- debugInteraction(
20680
- e,
20681
- `interaction ignored (state unchanged: "${state}")`,
20682
- );
20683
- return;
20684
- }
20685
-
20686
- if (skipDebounce) {
20687
- debugInteraction(e, `skip debounce, callback called with "${state}"`);
20688
- if (callback(e) !== false) {
20689
- currentState = state;
20690
- }
20691
- } else {
20692
- debugInteraction(
20693
- e,
20694
- `debounced ${debounce}ms, pending callback with "${state}"`,
20695
- );
20696
- debounceTimeout = setTimeout(() => {
20697
- debugInteraction(
20698
- e,
20699
- `debounce elapsed, callback called with "${state}"`,
20700
- );
20701
- if (callback(e) !== false) {
20702
- currentState = state;
20703
- }
20704
- }, debounce);
20705
- }
20706
- };
20707
- // no need to wait for change events (blur, enter key)
20708
- // we consider this as strong interactions requesting an immediate response
20709
- const stop = listenInputStateChange(
20710
- input,
20711
- (e) => {
20712
- onEvent(e, { skipDebounce: true });
20713
- },
20714
- { getState, changeOnEnter: true },
20715
- );
20716
- addTeardown(() => {
20717
- stop();
20718
- });
20719
- } else {
20720
- onEvent = (e) => {
20721
- clearTimeout(timeout);
20722
- const state = getState();
20723
- if (state === currentState) {
20724
- debugInteraction(
20725
- e,
20726
- `interaction ignored (state unchanged: "${state}")`,
20727
- );
20728
- return;
20729
- }
20730
- debugInteraction(e, `callback called with "${state}"`);
20731
- if (callback(e) !== false) {
20732
- currentState = state;
20733
- }
20734
- };
20735
- // Autocomplete, programmatic changes, form restoration
20736
- input.addEventListener("change", onEvent);
20737
- addTeardown(() => {
20738
- input.removeEventListener("change", onEvent);
20739
- });
20740
- }
20741
-
20742
- const onAsyncEvent = (e, options) => {
20743
- debugInteraction(e, `async event, scheduling callback`);
20744
- timeout = setTimeout(() => {
20745
- onEvent(e, options);
20746
- }, 0);
20747
- };
20748
-
20749
- // Standard user input (typing)
20750
- const onInput = (e) => {
20751
- onEvent(e);
20752
- };
20753
- input.addEventListener("input", onInput);
20754
- addTeardown(() => {
20755
- input.removeEventListener("input", onInput);
20756
- });
20757
-
20758
- // Form reset - need to check the form
20759
- const form = input.form;
20760
- if (form) {
20761
- // Form reset happens asynchronously, check value after reset completes
20762
- form.addEventListener("reset", onAsyncEvent);
20763
- addTeardown(() => {
20764
- form.removeEventListener("reset", onAsyncEvent);
20765
- });
20766
- }
20767
-
20768
- // Paste events (some browsers need special handling)
20769
- input.addEventListener("paste", onEvent);
20770
- addTeardown(() => {
20771
- input.removeEventListener("paste", onEvent);
20772
- });
20773
-
20774
- input.addEventListener("navi_ui_state_change", (e) => {
20775
- // radios are unchecked by an internal setUIState call when another radio is checked.
20776
- // navi_ui_state_change is dispatched whenever setUIState changes state, so we
20777
- // listen here to keep currentState in sync — otherwise input_effect thinks the
20778
- // radio is still checked and ignores the next user click as "state unchanged".
20779
- if (input.type === "radio") {
20780
- currentState = e.detail.value;
20781
- }
20782
- const clearEvent = findEvent(
20783
- e,
20784
- (eInChain) =>
20785
- eInChain.type === "navi_set_ui_state" && eInChain.detail.isClear,
20786
- );
20787
- const isClear = Boolean(clearEvent);
20788
- if (isClear) {
20789
- // "navi_clear" behaves like an async event
20790
- // a bit like form reset because
20791
- // our action will be updated async after the component re-renders
20792
- // and we need to wait that to happen to properly call action with the right value
20793
- debugInteraction(e, `navi_clear received, scheduling callback`);
20794
- onAsyncEvent(e, { skipDebounce: true });
20795
- }
20796
- });
20797
-
20798
- return teardown;
20799
- };
20800
-
20801
- const listenInputStateChange = (
20802
- input,
20803
- callback,
20804
- { getState, changeOnEnter },
20805
- ) => {
20806
- const [teardown, addTeardown] = createPubSub();
20807
-
20808
- let stateAtInteraction;
20809
- const oninput = () => {
20810
- stateAtInteraction = undefined;
20811
- };
20812
- const onkeydown = (e) => {
20813
- if (e.key === "Enter") {
20814
- /**
20815
- * Browser trigger a "change" event right after the enter is pressed
20816
- * if the input value has changed.
20817
- * We need to prevent the next change event otherwise we would request action twice
20818
- */
20819
- stateAtInteraction = getState();
20820
- if (changeOnEnter) {
20821
- onchange(e);
20822
- }
20823
- }
20824
- if (e.key === "Escape") {
20825
- /**
20826
- * Browser trigger a "change" event right after the escape is pressed
20827
- * if the input value has changed.
20828
- * We need to prevent the next change event otherwise we would request action when
20829
- * we actually want to cancel
20830
- */
20831
- stateAtInteraction = getState();
20832
- }
20833
- };
20834
- const onchange = (e) => {
20835
- if (stateAtInteraction !== undefined && getState() === stateAtInteraction) {
20836
- stateAtInteraction = undefined;
20837
- return;
20838
- }
20839
- callback(e);
20840
- };
20841
- input.addEventListener("input", oninput);
20842
- input.addEventListener("keydown", onkeydown);
20843
- input.addEventListener("change", onchange);
20844
- addTeardown(() => {
20845
- input.removeEventListener("input", oninput);
20846
- input.removeEventListener("keydown", onkeydown);
20847
- input.removeEventListener("change", onchange);
20848
- });
20849
-
20850
- {
20851
- // Handle programmatic value changes that don't trigger browser change events
20852
- //
20853
- // Problem: When input values are set programmatically (not by user typing),
20854
- // browsers don't fire the 'change' event. However, our application logic
20855
- // still needs to detect these changes.
20856
- //
20857
- // Example scenario:
20858
- // 1. User starts editing (letter key pressed, value set programmatically)
20859
- // 2. User doesn't type anything additional (this is the key part)
20860
- // 3. User clicks outside to finish editing
20861
- // 4. Without this code, no change event would fire despite the fact that the input value did change from its original state
20862
- //
20863
- // This distinction is crucial because:
20864
- //
20865
- // - If the user typed additional text after the initial programmatic value,
20866
- // the browser would fire change events normally
20867
- // - But when they don't type anything else, the browser considers it as "no user interaction"
20868
- // even though the programmatic initial value represents a meaningful change
20869
- //
20870
- // We achieve this by checking if the input value has changed between focus and blur without any user interaction
20871
- // if yes we fire the callback because input value did change
20872
- let stateAtStart = getState();
20873
- let interacted = false;
20874
-
20875
- const onfocus = () => {
20876
- interacted = false;
20877
- stateAtStart = getState();
20878
- };
20879
- const oninput = (e) => {
20880
- if (!e.isTrusted) {
20881
- // non trusted "input" events will be ignored by the browser when deciding to fire "change" event
20882
- // we ignore them too
20883
- return;
20884
- }
20885
- interacted = true;
20886
- };
20887
- const onblur = (e) => {
20888
- if (interacted) {
20889
- return;
20890
- }
20891
- if (stateAtStart === getState()) {
20892
- return;
20893
- }
20894
- callback(e);
20895
- };
20896
-
20897
- input.addEventListener("focus", onfocus);
20898
- input.addEventListener("input", oninput);
20899
- input.addEventListener("blur", onblur);
20900
- addTeardown(() => {
20901
- input.removeEventListener("focus", onfocus);
20902
- input.removeEventListener("input", oninput);
20903
- input.removeEventListener("blur", onblur);
20904
- });
20905
- }
20906
-
20907
- return teardown;
20908
- };
20909
-
20910
- const requestPseudoStateCheck = (element, detail) => {
20911
- dispatchInternalCustomEvent(
20912
- element,
20913
- "navi_pseudo_state_request_check",
20914
- detail,
20915
- );
20916
- // When a control has visible proxies mirroring its state (e.g. selectable
20917
- // radio with `navi-control-proxy-for`), re-check them too so they stay in
20918
- // sync with the real control.
20919
- for (const proxy of findControlProxies(element)) {
20920
- dispatchInternalCustomEvent(
20921
- proxy,
20922
- "navi_pseudo_state_request_check",
20923
- detail,
20924
- );
20925
- }
20926
- };
20927
- /**
20928
- * Called back whenever `element`'s pseudo state changes.
20929
- *
20930
- * Kept beside the element rather than announced to the DOM: an element that
20931
- * changes state has at most one or two interested parties, each known by name
20932
- * (the box drawing it, the accent color reading its computed style), and a
20933
- * CustomEvent costs its allocation and its capture/bubble walk whether anyone
20934
- * listens or not — paid on every element, on every state change. A lookup that
20935
- * finds nothing costs nothing.
20936
- *
20937
- * @param {Element} element
20938
- * @param {(pseudoState: object, oldPseudoState: object) => void} callback
20939
- * @returns {() => void} teardown
20940
- */
20941
- const subscribeToPseudoState = (element, callback) => {
20942
- let subscriberSet = pseudoStateSubscriberSetWeakMap.get(element);
20943
- if (!subscriberSet) {
20944
- subscriberSet = new Set();
20945
- pseudoStateSubscriberSetWeakMap.set(element, subscriberSet);
20946
- }
20947
- subscriberSet.add(callback);
20948
- return () => {
20949
- subscriberSet.delete(callback);
20950
- };
20951
- };
20952
- const pseudoStateSubscriberSetWeakMap = new WeakMap();
20953
- const notifyPseudoStateSubscribers = (element, value, oldValue) => {
20954
- const subscriberSet = pseudoStateSubscriberSetWeakMap.get(element);
20955
- if (!subscriberSet) {
20956
- return;
20957
- }
20958
- for (const subscriber of subscriberSet) {
20959
- subscriber(value, oldValue);
20960
- }
20961
- };
20962
-
20963
- const PSEUDO_CLASSES = {};
20964
- Object.assign(PSEUDO_CLASSES, {
20965
- ":valid": {
20966
- attribute: "data-valid",
20967
- test: (el) => el.matches(":valid"),
20968
- },
20969
- ":invalid": {
20970
- attribute: "data-invalid",
20971
- test: (el) => el.matches(":invalid"),
20972
- },
20973
- ":visited": {
20974
- attribute: "data-visited",
20975
- },
20976
- // Written by whoever knows the current url — a Link from its href, a Button
20977
- // from its route — so it lives here rather than with one of them.
20978
- ":-navi-href-current": {
20979
- attribute: "data-href-current",
20980
- },
20981
- });
20982
- const definePseudoClass = (pseudoClass, definition) => {
20983
- PSEUDO_CLASSES[pseudoClass] = definition;
20984
- };
20985
-
20986
- // On touch devices (hover: none), browsers synthesize mouseenter/mouseleave
20987
- // from touch events but never fire mouseleave when the finger lifts, leaving
20988
- // el.matches(":hover") stuck at true. This causes hover styles (e.g. input
20989
- // background highlight) to remain visible long after the user has stopped
20990
- // touching the element. Checking (hover: hover) lets us skip hover tracking
20991
- // entirely on touch-only devices where persistent hover makes no sense.
20992
- const hoverSupported = window.matchMedia("(hover: hover)").matches;
20993
- definePseudoClass(":hover", {
20994
- attribute: "data-hover",
20995
- setup: (el, callback) => {
20996
- if (!hoverSupported) {
20997
- return () => {};
20998
- }
20999
- const recheckProxy = (e) => {
21000
- for (const proxy of findControlProxies(el)) {
21001
- requestPseudoStateCheck(proxy, { event: e });
21002
- }
21003
- };
21004
- const recheckProxyTarget = (e) => {
21005
- const proxyTarget = findControlProxyTarget(el);
21006
- if (proxyTarget) {
21007
- requestPseudoStateCheck(proxyTarget, { event: e });
21008
- }
21009
- };
21010
- let onmouseenter = (e) => {
21011
- callback();
21012
- recheckProxy(e);
21013
- recheckProxyTarget(e);
21014
- };
21015
- let onmouseleave = (e) => {
21016
- callback();
21017
- recheckProxy(e);
21018
- recheckProxyTarget(e);
21019
- };
21020
-
21021
- if (el.tagName === "LABEL") {
21022
- // input.matches(":hover") is true when hovering the label
21023
- // so when label is hovered/not hovered we need to recheck the input too
21024
- const recheckInput = (e) => {
21025
- if (el.htmlFor) {
21026
- const input = document.getElementById(el.htmlFor);
21027
- if (!input) {
21028
- // cannot find the input for this label in the DOM
21029
- return;
21030
- }
21031
- requestPseudoStateCheck(input, { event: e });
21032
- return;
21033
- }
21034
- const input = el.querySelector("input, textarea, select");
21035
- if (!input) {
21036
- // label does not contain an input
21037
- return;
21038
- }
21039
- requestPseudoStateCheck(input, { event: e });
21040
- };
21041
- const _onmouseenter = onmouseenter;
21042
- onmouseenter = (e) => {
21043
- recheckInput(e);
21044
- _onmouseenter(e);
21045
- };
21046
- const _onmouseleave = onmouseleave;
21047
- onmouseleave = (e) => {
21048
- recheckInput(e);
21049
- _onmouseleave(e);
21050
- };
21051
- }
21052
-
21053
- el.addEventListener("mouseenter", onmouseenter);
21054
- el.addEventListener("mouseleave", onmouseleave);
21055
- return () => {
21056
- el.removeEventListener("mouseenter", onmouseenter);
21057
- el.removeEventListener("mouseleave", onmouseleave);
21058
- };
21059
- },
21060
- test: (el) => {
21061
- if (!hoverSupported) {
21062
- return false;
21063
- }
21064
- if (el.matches(":hover")) {
21065
- return true;
21066
- }
21067
- for (const proxy of findControlProxies(el)) {
21068
- if (proxy.matches(":hover")) {
21069
- return true;
21070
- }
21071
- }
21072
- return false;
21073
- },
21074
- });
21075
- definePseudoClass(":disabled", {
21076
- attribute: "data-disabled",
21077
- add: (el) => {
21078
- if (
21079
- el.tagName === "BUTTON" ||
21080
- el.tagName === "INPUT" ||
21081
- el.tagName === "SELECT" ||
21082
- el.tagName === "TEXTAREA"
21083
- ) {
21084
- el.disabled = true;
21085
- }
21086
- },
21087
- remove: (el) => {
21088
- if (
21089
- el.tagName === "BUTTON" ||
21090
- el.tagName === "INPUT" ||
21091
- el.tagName === "SELECT" ||
21092
- el.tagName === "TEXTAREA"
21093
- ) {
21094
- el.disabled = false;
21095
- }
21096
- },
21097
- });
21098
- definePseudoClass(":read-only", {
21099
- attribute: "data-readonly",
21100
- add: (el) => {
21101
- if (
21102
- el.tagName === "INPUT" ||
21103
- el.tagName === "SELECT" ||
21104
- el.tagName === "TEXTAREA"
21105
- ) {
21106
- if (el.type === "checkbox" || el.type === "radio") {
21107
- // there is no readOnly for checkboxes/radios
21108
- return;
21109
- }
21110
- // el.readOnly = true;
21111
- }
21112
- },
21113
- remove: (el) => {
21114
- if (
21115
- el.tagName === "INPUT" ||
21116
- el.tagName === "SELECT" ||
21117
- el.tagName === "TEXTAREA"
21118
- ) {
21119
- if (el.type === "checkbox" || el.type === "radio") {
21120
- // there is no readOnly for checkboxes/radios
21121
- return;
21122
- }
21123
- // el.readOnly = false;
21124
- }
21125
- },
21126
- });
21127
- definePseudoClass(":checked", {
21128
- attribute: "data-checked",
21129
- setup: (el, callback) => {
21130
- if (el.type === "checkbox") {
21131
- // Listen to user interactions
21132
- el.addEventListener("input", callback);
21133
- // Intercept programmatic changes to .checked property
21134
- const originalDescriptor = Object.getOwnPropertyDescriptor(
21135
- HTMLInputElement.prototype,
21136
- "checked",
21137
- );
21138
- Object.defineProperty(el, "checked", {
21139
- get: originalDescriptor.get,
21140
- set(value) {
21141
- originalDescriptor.set.call(this, value);
21142
- callback();
21143
- },
21144
- configurable: true,
21145
- });
21146
- return () => {
21147
- // Restore original property descriptor
21148
- Object.defineProperty(el, "checked", originalDescriptor);
21149
- el.removeEventListener("input", callback);
21150
- };
21151
- }
21152
- if (el.type === "radio") {
21153
- // Listen to changes on the radio
21154
- el.addEventListener("input", callback);
21155
- // Intercept programmatic changes to .checked property
21156
- const originalDescriptor = Object.getOwnPropertyDescriptor(
21157
- HTMLInputElement.prototype,
21158
- "checked",
21159
- );
21160
- Object.defineProperty(el, "checked", {
21161
- get: originalDescriptor.get,
21162
- set(value) {
21163
- originalDescriptor.set.call(this, value);
21164
- callback();
21165
- },
21166
- configurable: true,
21167
- });
21168
- return () => {
21169
- el.removeEventListener("input", callback);
21170
- // Restore original property descriptor
21171
- Object.defineProperty(el, "checked", originalDescriptor);
21172
- };
21173
- }
21174
- if (el.tagName === "INPUT") {
21175
- el.addEventListener("input", callback);
21176
- return () => {
21177
- el.removeEventListener("input", callback);
21178
- };
21179
- }
21180
- return () => {};
21181
- },
21182
- test: (el) => el.matches(":checked"),
21183
- });
21184
- definePseudoClass(":active", {
21185
- attribute: "data-active",
21186
- setup: (el, callback) => {
21187
- // I'ts recommended to use :-navi-pressed over :active for interactive elements.
21188
- const onPointerDown = () => {
21189
- const onRelease = () => {
21190
- document.removeEventListener("pointercancel", onRelease, true);
21191
- document.removeEventListener("pointerup", onRelease, true);
21192
- callback();
21193
- };
21194
- document.addEventListener("pointercancel", onRelease, true);
21195
- document.addEventListener("pointerup", onRelease, true);
21196
- callback();
21197
- };
21198
- el.addEventListener("pointerdown", onPointerDown);
21199
- return () => {
21200
- el.removeEventListener("pointerdown", onPointerDown);
21201
- };
21202
- },
21203
- test: (el) => el.matches(":active"),
21204
- });
21205
-
21206
- // The current input modality: true after a keyboard navigation key (arrow keys,
21207
- // Escape, Enter, Ctrl, Alt, Shift, Space — Space ignored on editable fields),
21208
- // false after a pointer interaction. Updated by the listeners in the
21209
- // focus_classes block below. At module scope so isMatchingFocusVisible (used
21210
- // here and in control_hooks.jsx) can read it.
21211
- let keyboardNavigationUsed = false;
21212
-
21213
- // HOW FOCUS-VISIBLE IS DECIDED (the details behind isMatchingFocusVisible)
21214
- //
21215
- // Two rules, depending on what `el` is:
21216
- //
21217
- // 1. An EDITABLE target (text-ish input, textarea, contenteditable — anything
21218
- // whose whole point is keyboard input, see isEditableTarget) shows its ring
21219
- // whenever it holds the focus, however the focus arrived — mouse,
21220
- // programmatic, even a focus({ focusVisible: false }). Being focused, for
21221
- // such a field, means being about to type, and that is what the ring
21222
- // announces. Its check is on :focus rather than :focus-visible on purpose:
21223
- // the native :focus-visible obeys the focusVisible option, which callers
21224
- // set from the modality without knowing what they are focusing.
21225
- //
21226
- // 2. Anything else needs BOTH the native :focus-visible match AND the current
21227
- // modality being keyboard (`keyboardNavigationUsed`).
21228
- //
21229
- // The native match alone cannot be trusted, because we routinely take the
21230
- // pointer out of the browser's hands: a picker opens on mousedown and calls
21231
- // preventDefault() so the browser does not move focus itself (see
21232
- // picker_custom.jsx). The browser therefore never registers that a pointer
21233
- // was what moved focus, and whatever focus-visible state the previous
21234
- // keyboard interaction left behind stays true — even across the
21235
- // programmatic focus({ focusVisible: false }) that follows. Close a picker
21236
- // with Escape (ring on the trigger, rightly) and click it open again: the
21237
- // popup's own focusable would come up ringed, from a mouse press.
21238
- //
21239
- // `keyboardNavigationUsed` has no such blind spot — it is set by navigation
21240
- // keydowns and cleared by pointerdown, whatever anyone prevents afterwards
21241
- // — so it is the authority for everything rule 1 does not cover.
21242
- //
21243
- // Ring INHERITANCE (a controlled element ringing because its aria-controls
21244
- // controller is focused) stays gated on the keyboard modality even for an
21245
- // editable controller — see hasIndirectFocus: propagating a ring promises
21246
- // keyboard shortcuts will drive the controlled element, a promise that only
21247
- // holds once a physical keyboard has actually been used.
21248
- /**
21249
- * Whether `el` should currently show a focus ring — the enriched
21250
- * :focus-visible behind every [data-focus-visible] navi renders.
21251
- * Use this instead of a bare el.matches(":focus-visible") wherever
21252
- * focus-visible is evaluated; the comment above details the rules.
21253
- * @param {Element} el
21254
- * @returns {boolean}
21255
- */
21256
- const isMatchingFocusVisible = (el) => {
21257
- if (isEditableTarget(el)) {
21258
- return el.matches(":focus");
21259
- }
21260
- if (!el.matches(":focus-visible")) {
21261
- return false;
21262
- }
21263
- if (!keyboardNavigationUsed) {
21264
- return false;
21265
- }
21266
- return true;
21267
- };
21268
-
21269
- // Elements that invite keyboard input: focusing one — even with the mouse —
21270
- // means the user is about to type, so they always warrant a visible focus.
21271
- // Also used to ignore the Space key as a navigation key while typing, and by
21272
- // programmatic focus (focus_transfer.js) to pass focusVisible: true so the
21273
- // native :focus-visible agrees with the ring rule 1 above draws.
21274
- const EDITABLE_INPUT_TYPE_SET = new Set([
21275
- "text",
21276
- "search",
21277
- "url",
21278
- "email",
21279
- "password",
21280
- "tel",
21281
- "number",
21282
- "date",
21283
- "time",
21284
- "datetime-local",
21285
- "month",
21286
- "week",
21287
- ]);
21288
- const isEditableTarget = (target) => {
21289
- if (!target) {
21290
- return false;
21291
- }
21292
- const tag = target.tagName;
21293
- if (tag === "TEXTAREA") {
21294
- return !target.readOnly;
21295
- }
21296
- if (tag === "INPUT") {
21297
- if (!target.type || EDITABLE_INPUT_TYPE_SET.has(target.type)) {
21298
- return !target.readOnly;
21299
- }
21300
- }
21301
- if (target.isContentEditable) {
21302
- return true;
21303
- }
21304
- return false;
21305
- };
21306
-
21307
- // The current modality, for code deciding whether something it is about to
21308
- // focus should show a ring — a slide arriving, a popup opening. The question is
21309
- // "was the user on the keyboard when this was asked for", which is what this
21310
- // flag says; the element that happens to hold the focus right now says nothing
21311
- // about it (it may have been focused programmatically, ring or no ring, by the
21312
- // travel before this one).
21313
- const isKeyboardModality = () => keyboardNavigationUsed;
21314
-
21315
- {
21316
- // We implement :focus and :focus-visible with enriched semantics:
21317
- // an element is considered focused not only when it natively has focus, but also
21318
- // when a "focus proxy" element has focus (e.g. a read-only range input delegates
21319
- // focus to a sibling span) or when a controlling element has focus (e.g. a combobox
21320
- // input with aria-controls pointing to a listbox — the listbox should appear focused
21321
- // while the input is focused).
21322
- //
21323
- // We intentionally reuse the native :focus / :focus-visible names rather than
21324
- // introducing new navi-specific pseudo-classes (e.g. :-navi-focus). This is a
21325
- // deliberate exception: all existing CSS and code written as [data-focus] or
21326
- // [data-focus-visible] automatically benefits from the enriched behavior without
21327
- // any changes. A separate navi-specific class would require updating every
21328
- // component.
21329
- //
21330
- // When a controller element (e.g. combobox input) gains or loses focus,
21331
- // notify the elements it controls via aria-controls so they re-check their
21332
- // focus state. `requestPseudoStateCheck` also re-checks the controlled
21333
- // element's proxy (if any), so the visible proxy mirrors the hidden real
21334
- // input's inherited focus.
21335
- const notifyAriaControlled = (el, e) => {
21336
- const controlledIds = el.getAttribute("aria-controls");
21337
- if (!controlledIds) {
21338
- return;
21339
- }
21340
- for (const id of controlledIds.split(" ")) {
21341
- const controlled = document.getElementById(id);
21342
- if (controlled) {
21343
- requestPseudoStateCheck(controlled, { event: e });
21344
- }
21345
- }
21346
- };
21347
- // Tracks whether the user has pressed a keyboard navigation key (arrow keys,
21348
- // Escape, Enter, Ctrl, Alt, Shift, Space) since the last pointer interaction.
21349
- // Space is ignored when the target is an editable field.
21350
- // This flag is used to gate focus-visible inheritance via aria-controls:
21351
- // on mobile (or when the user hasn't used keyboard nav yet) an input that
21352
- // controls a radio should not cause the radio to show a focus ring.
21353
- // (Declared at module scope — see keyboardNavigationUsed above.)
21354
- const NAVIGATION_KEY_SET = new Set([
21355
- "ArrowUp",
21356
- "ArrowDown",
21357
- "ArrowLeft",
21358
- "ArrowRight",
21359
- "Escape",
21360
- "Enter",
21361
- "Control",
21362
- "Alt",
21363
- "Shift",
21364
- " ",
21365
- "Tab",
21366
- ]);
21367
- document.addEventListener(
21368
- "keydown",
21369
- (e) => {
21370
- if (!NAVIGATION_KEY_SET.has(e.key)) {
21371
- return;
21372
- }
21373
- if (e.key === " " && isEditableTarget(e.target)) {
21374
- return;
21375
- }
21376
- keyboardNavigationUsed = true;
21377
- },
21378
- { capture: true },
21379
- );
21380
- document.addEventListener(
21381
- "pointerdown",
21382
- () => {
21383
- keyboardNavigationUsed = false;
21384
- },
21385
- { capture: true },
21386
- );
21387
-
21388
- // A keystroke flips keyboardNavigationUsed, which can turn :focus-visible on —
21389
- // but only for the element that holds focus, directly or through aria-controls
21390
- // / a proxy. Pressing a key cannot reveal a focus ring on an unfocused element.
21391
- // So a single shared handler re-checks just that focus chain (the active
21392
- // element, what it controls, and its proxy — the last two via
21393
- // requestPseudoStateCheck / notifyAriaControlled). This runs in the bubble
21394
- // phase, after the capture-phase listener above has updated the flag.
21395
- //
21396
- // The alternative — each registered :focus-visible element adding its own
21397
- // document keydown listener that re-tests itself — makes one keypress cost
21398
- // O(number-of-boxes) full-document [aria-controls] / proxy queries, since every
21399
- // unfocused element falls through matches(":focus-visible") into hasIndirectFocus.
21400
- const recheckFocusChainOnKey = (e) => {
21401
- const active = document.activeElement;
21402
- if (!active || active === document.body) {
21403
- return;
21404
- }
21405
- requestPseudoStateCheck(active, { event: e });
21406
- notifyAriaControlled(active, e);
21407
- };
21408
- document.addEventListener("keydown", recheckFocusChainOnKey);
21409
- document.addEventListener("keyup", recheckFocusChainOnKey);
21410
-
21411
- // Returns true when el holds focus indirectly — either because a controlling
21412
- // element (aria-controls) has focus, or because el is a proxy whose target
21413
- // is itself controlled by a focused element.
21414
- const hasIndirectFocus = (el, { requireFocusVisible = false } = {}) => {
21415
- // No ring inheritance without a keyboard: an editable target draws its own
21416
- // ring on any focus (see isMatchingFocusVisible), but propagating that ring
21417
- // to a controlled element (aria-controls) is a promise that keyboard
21418
- // shortcuts will drive it — a promise that holds only once a physical
21419
- // keyboard has actually been used. On touch devices the flag stays false
21420
- // and a focused search input keeps its ring to itself.
21421
- if (requireFocusVisible && !keyboardNavigationUsed) {
21422
- return false;
21423
- }
21424
- // A controller/proxy counts as focused for inheritance via the same rule
21425
- // used everywhere: :focus for plain inheritance, isMatchingFocusVisible for
21426
- // the focus-visible variant (so a mouse-focused controller doesn't propagate
21427
- // a ring).
21428
- const isFocusedTarget = (target) =>
21429
- requireFocusVisible
21430
- ? isMatchingFocusVisible(target)
21431
- : target.matches(":focus");
21432
- // Both branches of isFocusedTarget rest on :focus / :focus-visible, and only
21433
- // one element in the document can match those: document.activeElement. So
21434
- // the single controller worth testing is known upfront — asking the document
21435
- // for every [aria-controls] would collect candidates that cannot qualify,
21436
- // once per element and again on every re-check, on a document each new
21437
- // element makes bigger.
21438
- const isControlledBy = (target) => {
21439
- const id = target.id;
21440
- if (!id) {
21441
- return false;
21442
- }
21443
- const activeElement = document.activeElement;
21444
- if (!activeElement || activeElement === document.body) {
21445
- return false;
21446
- }
21447
- if (!activeElement.matches(`[aria-controls~="${id}"]`)) {
21448
- return false;
21449
- }
21450
- // A controller inside the element it controls means focus is already
21451
- // native (:focus-within) — nothing to inherit.
21452
- if (target.contains(activeElement)) {
21453
- return false;
21454
- }
21455
- return isFocusedTarget(activeElement);
21456
- };
21457
- if (isControlledBy(el)) {
21458
- return true;
21459
- }
21460
- const proxyTarget = findControlProxyTarget(el);
21461
- if (proxyTarget) {
21462
- if (isFocusedTarget(proxyTarget)) {
21463
- return true;
21464
- }
21465
- if (isControlledBy(proxyTarget)) {
21466
- return true;
21467
- }
21468
- }
21469
- return false;
21470
- };
21471
-
21472
- // One registration per element for everything focus-related, shared by
21473
- // :focus, :focus-visible and :focus-within: they all react to the same
21474
- // focusin/focusout, and they all re-check through the same callback (see
21475
- // initPseudoStyles), which the Set turns into one call.
21476
- const focusTrackingWeakMap = new WeakMap();
21477
- const trackFocus = (el, callback) => {
21478
- let tracking = focusTrackingWeakMap.get(el);
21479
- if (!tracking) {
21480
- const callbackSet = new Set();
21481
- const onFocusChange = (e) => {
21482
- for (const trackedCallback of callbackSet) {
21483
- trackedCallback();
21484
- }
21485
- notifyAriaControlled(el, e);
21486
- };
21487
- el.addEventListener("focusin", onFocusChange);
21488
- el.addEventListener("focusout", onFocusChange);
21489
- tracking = {
21490
- callbackSet,
21491
- teardown: () => {
21492
- el.removeEventListener("focusin", onFocusChange);
21493
- el.removeEventListener("focusout", onFocusChange);
21494
- focusTrackingWeakMap.delete(el);
21495
- },
21496
- };
21497
- focusTrackingWeakMap.set(el, tracking);
21498
- observeAriaControls();
21499
- }
21500
- tracking.callbackSet.add(callback);
21501
- return () => {
21502
- tracking.callbackSet.delete(callback);
21503
- if (tracking.callbackSet.size === 0) {
21504
- tracking.teardown();
21505
- }
21506
- };
21507
- };
21508
- // When aria-controls changes on a focused element, what it used to control
21509
- // and what it controls now both re-check their inherited focus. One observer
21510
- // on the document rather than one per tracked element: the attribute changes
21511
- // rarely, on few elements, while thousands are tracked — and it can be set
21512
- // after the element was, so "has it at setup" would miss it.
21513
- let ariaControlsObserver = null;
21514
- const observeAriaControls = () => {
21515
- if (ariaControlsObserver) {
21516
- return;
21517
- }
21518
- ariaControlsObserver = new MutationObserver((mutations) => {
21519
- for (const mutation of mutations) {
21520
- const el = mutation.target;
21521
- if (!focusTrackingWeakMap.has(el)) {
21522
- continue;
21523
- }
21524
- if (!el.matches(":focus-within")) {
21525
- continue;
21526
- }
21527
- const oldIds = (mutation.oldValue || "").split(" ").filter(Boolean);
21528
- for (const id of oldIds) {
21529
- const controlled = document.getElementById(id);
21530
- if (controlled) {
21531
- requestPseudoStateCheck(controlled, {});
21532
- }
21533
- }
21534
- notifyAriaControlled(el, {});
21535
- }
21536
- });
21537
- ariaControlsObserver.observe(document.documentElement, {
21538
- subtree: true,
21539
- attributes: true,
21540
- attributeFilter: ["aria-controls"],
21541
- attributeOldValue: true,
21542
- });
21543
- };
21544
-
21545
- definePseudoClass(":focus", {
21546
- attribute: "data-focus",
21547
- setup: trackFocus,
21548
- test: (el) => {
21549
- if (el.matches(":focus")) {
21550
- return true;
21551
- }
21552
- if (hasIndirectFocus(el)) {
21553
- return true;
21554
- }
21555
- return false;
21556
- },
21557
- });
21558
- definePseudoClass(":focus-visible", {
21559
- attribute: "data-focus-visible",
21560
- // No per-element keydown/keyup listener: the shared recheckFocusChainOnKey
21561
- // handler re-checks the focused element (the only one a keystroke can turn
21562
- // focus-visible) so a keypress stays O(1), not O(number-of-boxes).
21563
- setup: trackFocus,
21564
- test: (el) => {
21565
- if (isMatchingFocusVisible(el)) {
21566
- return true;
21567
- }
21568
- if (hasIndirectFocus(el, { requireFocusVisible: true })) {
21569
- return true;
21570
- }
21571
- return false;
21572
- },
21573
- });
21574
- definePseudoClass(":focus-within", {
21575
- attribute: "data-focus-within",
21576
- setup: trackFocus,
21577
- test: (el) => {
21578
- if (el.matches(":focus-within")) {
21579
- return true;
21580
- }
21581
- if (hasIndirectFocus(el)) {
21582
- return true;
21583
- }
21584
- if (el.contains(document.activeElement)) {
21585
- // for some reason :focus-within sometimes is false while focus is within...
21586
- // (popover with chrome for some reason)
21587
- return true;
21588
- }
21589
- return false;
21590
- },
21591
- });
21592
- }
21593
-
21594
- Object.assign(PSEUDO_CLASSES, {
21595
- ":-navi-pointed": {
21596
- attribute: "data-pointed",
21597
- },
21598
- ":-navi-pointed-by-mouse": {
21599
- attribute: "data-pointed-by-mouse",
21600
- },
21601
- ":-navi-pointed-by-keyboard": {
21602
- attribute: "data-pointed-by-keyboard",
21603
- },
21604
- ":-navi-pointed-by-proxy": {
21605
- attribute: "data-pointed-by-proxy",
21606
- },
21607
- ":-navi-selected": {
21608
- attribute: "data-selected",
21609
- },
21610
- ":-navi-loading": {
21611
- attribute: "data-loading",
21612
- },
21613
- ":-navi-status-info": {
21614
- attribute: "data-status-info",
21615
- },
21616
- ":-navi-status-success": {
21617
- attribute: "data-status-success",
21618
- },
21619
- ":-navi-status-warning": {
21620
- attribute: "data-status-warning",
21621
- },
21622
- ":-navi-status-error": {
21623
- attribute: "data-status-error",
21624
- },
21625
- ":-navi-expanded": {
21626
- attribute: "data-expanded",
21627
- },
21628
- ":-navi-void": {
21629
- attribute: "data-void",
21630
- },
21631
- "::highlight": {},
21632
- });
21633
- definePseudoClass(":-navi-has-value", {
21634
- attribute: "data-has-value",
21635
- setup: (el, callback) => {
21636
- const controlHost = findControlHost(el) || el;
21637
- return addInputEffect(controlHost, callback);
21638
- },
21639
- test: (el) => {
21640
- if (isControlHost(el)) {
21641
- const uiState = getUIStateFromElement(el);
21642
- if (uiState === undefined || uiState === "") {
21643
- return false;
21644
- }
21645
- return true;
21646
- }
21647
- if (el.value === "") {
21648
- return false;
21649
- }
21650
- return true;
21651
- },
21652
- });
21653
- {
21654
- const pressedElements = new WeakSet();
21655
- definePseudoClass(":-navi-pressed", {
21656
- attribute: "data-pressed",
21657
- setup: (el, callback) => {
21658
- // Prefer :-navi-pressed over :active for interactive elements because:
21659
- // - :active only tracks the primary (left) button; right-click and touch
21660
- // long-press do not trigger :active reliably across browsers.
21661
- // - :-navi-pressed explicitly ignores non-primary buttons (e.g. right-click)
21662
- // and correctly clears pressed state when a context menu opens on long-press,
21663
- // which would otherwise leave the element stuck in a pressed appearance.
21664
-
21665
- // Note: it might be tempting to use el.setPointerCapture() here so that pointerup
21666
- // always fires on el regardless of where the pointer is released. However,
21667
- // pointer capture routes all subsequent pointer events to the capturing element,
21668
- // which means any other element in the tree that expects to receive pointerup,
21669
- // mouseup, click, etc. after a pointerdown will silently not get them.
21670
- // For example a <label> that reacts to mousedown + click, or a third-party
21671
- // library that attaches its own listeners, would break because an ancestor
21672
- // grabbed the pointer out from under them.
21673
- // To avoid forcing every such element to declare an opt-out attribute
21674
- // (e.g. navi-own-pointer-capture) we simply listen on document instead,
21675
- // which is safe and does not interfere with anyone else's event flow.
21676
- const onPointerDown = (e) => {
21677
- if (e.button !== 0) {
21678
- // only left pointer (mouse left click, touch, pen)
21679
- return;
21680
- }
21681
- pressedElements.add(el);
21682
- const onRelease = () => {
21683
- pressedElements.delete(el);
21684
- document.removeEventListener("pointercancel", onRelease, true);
21685
- document.removeEventListener("pointerup", onRelease, true);
21686
- document.removeEventListener("contextmenu", onContextMenu, true);
21687
- callback();
21688
- };
21689
- const onContextMenu = (e) => {
21690
- // On touch devices, a long-press triggers the context menu.
21691
- // If the context menu is not prevented, it means it will open and the
21692
- // pointer events (pointerup, lostpointercapture) won't fire normally,
21693
- // leaving the element stuck in pressed state. We clear it manually.
21694
- // e.button === -1 means the event was synthesized from a long-press (not a real mouse click).
21695
- if (e.button === -1 && !e.defaultPrevented) {
21696
- pressedElements.delete(el);
21697
- document.removeEventListener("pointercancel", onRelease, true);
21698
- document.removeEventListener("pointerup", onRelease, true);
21699
- document.removeEventListener("contextmenu", onContextMenu, true);
21700
- callback();
21701
- }
21702
- };
21703
- document.addEventListener("pointercancel", onRelease, true);
21704
- document.addEventListener("pointerup", onRelease, true);
21705
- document.addEventListener("contextmenu", onContextMenu, true);
21706
- callback();
21707
- };
21708
- el.addEventListener("pointerdown", onPointerDown);
21709
- return () => {
21710
- el.removeEventListener("pointerdown", onPointerDown);
21711
- pressedElements.delete(el);
21712
- };
21713
- },
21714
- test: (el) => pressedElements.has(el),
21715
- });
21716
- }
21717
-
21718
- {
21719
- definePseudoClass(":-navi-drag-grabbed", {
21720
- attribute: "navi-drag-grabbed",
21721
- setup: (el, callback) => {
21722
- const onGrab = () => {
21723
- callback();
21724
- const onRelease = () => {
21725
- el.removeEventListener("navi_drag_release", onRelease);
21726
- callback();
21727
- };
21728
- el.addEventListener("navi_drag_release", onRelease);
21729
- };
21730
- el.addEventListener("navi_drag_grab", onGrab);
21731
- return () => {
21732
- el.removeEventListener("navi_drag_grab", onGrab);
21733
- };
21734
- },
21735
- test: (el) => el.hasAttribute("data-drag-grabbed"),
21736
- });
21737
- definePseudoClass(":-navi-dragging", {
21738
- attribute: "navi-dragging",
21739
- setup: (el, callback) => {
21740
- const onStart = () => {
21741
- callback();
21742
- const onRelease = () => {
21743
- el.removeEventListener("navi_drag_release", onRelease);
21744
- callback();
21745
- };
21746
- el.addEventListener("navi_drag_release", onRelease);
21747
- };
21748
- el.addEventListener("navi_drag_start", onStart);
21749
- return () => {
21750
- el.removeEventListener("navi_drag_start", onStart);
21751
- };
21752
- },
21753
- test: (el) => el.hasAttribute("data-dragging"),
21754
- });
21755
- }
21756
-
21757
- const EMPTY_STATE = {};
21758
- const elementToImpactWeakMap = new WeakMap();
21759
- const initPseudoStyles = (
21760
- element,
21761
- {
21762
- pseudoClasses,
21763
- pseudoState, // ":disabled", ":read-only", ":-navi-loading", etc...
21764
- effect,
21765
- elementToImpact = element,
21766
- elementListeningPseudoState,
21767
- },
21768
- ) => {
21769
- elementToImpactWeakMap.set(element, elementToImpact);
21770
- if (elementListeningPseudoState === element) {
21771
- console.warn(
21772
- `elementListeningPseudoState should not be the same as element to avoid infinite loop`,
21773
- );
21774
- elementListeningPseudoState = null;
21775
- }
21776
-
21777
- const proxyTarget = findControlProxyTarget(element);
21778
-
21779
- const onStateChange = (value, oldValue) => {
21780
- effect?.(value, oldValue);
21781
- if (elementListeningPseudoState) {
21782
- notifyPseudoStateSubscribers(
21783
- elementListeningPseudoState,
21784
- value,
21785
- oldValue,
21786
- );
21787
- }
21788
- // When this element's state changes, notify any proxy element that mirrors it
21789
- // so it can re-check and visually reflect the new state.
21790
- for (const proxy of findControlProxies(element)) {
21791
- requestPseudoStateCheck(proxy, {});
21792
- }
21793
- };
21794
-
21795
- if (!pseudoClasses || pseudoClasses.length === 0) {
21796
- onStateChange(EMPTY_STATE);
21797
- return () => {};
21798
- }
21799
-
21800
- const [teardown, addTeardown] = createPubSub();
21801
-
21802
- let state;
21803
- const checkPseudoClasses = () => {
21804
- let someChange = false;
21805
- const currentState = {};
21806
- for (const pseudoClass of pseudoClasses) {
21807
- const pseudoClassDefinition = PSEUDO_CLASSES[pseudoClass];
21808
- if (!pseudoClassDefinition) {
21809
- console.warn(`Unknown pseudo class: ${pseudoClass}`);
21810
- continue;
21811
- }
21812
- let currentValue;
21813
- if (
21814
- pseudoState &&
21815
- Object.hasOwn(pseudoState, pseudoClass) &&
21816
- pseudoState[pseudoClass] !== undefined
21817
- ) {
21818
- currentValue = pseudoState[pseudoClass];
21819
- } else {
21820
- const { test } = pseudoClassDefinition;
21821
- if (test) {
21822
- currentValue = test(element, pseudoState);
21823
- }
21824
- }
21825
- // If this element is a proxy for another (navi-control-proxy-for="targetId"),
21826
- // inherit the target's active pseudo-state when the element itself isn't in that state.
21827
- // We check the target's elementToImpact (not the target itself) because the
21828
- // data-* attribute may be set on a different element (e.g. pseudoStateSelector).
21829
- if (!currentValue && proxyTarget) {
21830
- const { attribute } = pseudoClassDefinition;
21831
- if (attribute) {
21832
- const targetElementToImpact =
21833
- elementToImpactWeakMap.get(proxyTarget) || proxyTarget;
21834
- if (targetElementToImpact.hasAttribute(attribute)) {
21835
- currentValue = true;
21836
- }
21837
- }
21838
- }
21839
- currentState[pseudoClass] = currentValue;
21840
- const oldValue = state ? state[pseudoClass] : undefined;
21841
- if (oldValue !== currentValue || !state) {
21842
- someChange = true;
21843
- const { attribute, add, remove } = pseudoClassDefinition;
21844
- if (currentValue) {
21845
- if (attribute) {
21846
- elementToImpact.setAttribute(attribute, "");
21847
- }
21848
- add?.(element);
21849
- } else {
21850
- if (attribute) {
21851
- elementToImpact.removeAttribute(attribute);
21852
- }
21853
- remove?.(element);
21854
- }
21855
- }
21856
- }
21857
- if (!someChange) {
21858
- return;
21859
- }
21860
- const oldState = state;
21861
- state = currentState;
21862
- onStateChange(state, oldState);
21863
- };
21864
-
21865
- addTeardown(
21866
- subscribeToPseudoState(element, (pseudoState, oldPseudoState) => {
21867
- state = pseudoState;
21868
- onStateChange(state, oldPseudoState);
21869
- }),
21870
- );
21871
- // One function for every way a re-check can be asked, so that setups
21872
- // registering it side by side (the focus tracking, for one) hold the same
21873
- // callback and call it once.
21874
- const requestCheck = () => {
21875
- checkPseudoClasses();
21876
- };
21877
- element.addEventListener("navi_pseudo_state_request_check", requestCheck);
21878
- addTeardown(() => {
21879
- element.removeEventListener(
21880
- "navi_pseudo_state_request_check",
21881
- requestCheck,
21882
- );
21883
- });
21884
-
21885
- for (const pseudoClass of pseudoClasses) {
21886
- const pseudoClassDefinition = PSEUDO_CLASSES[pseudoClass];
21887
- if (!pseudoClassDefinition) {
21888
- console.warn(`Unknown pseudo class: ${pseudoClass}`);
21889
- continue;
21890
- }
21891
- const { setup } = pseudoClassDefinition;
21892
- if (setup) {
21893
- const cleanup = setup(element, requestCheck);
21894
- addTeardown(cleanup);
21895
- }
21896
- }
21897
- checkPseudoClasses();
21898
-
21899
- return teardown;
21900
- };
21901
-
21902
- const applyStyle = (
21903
- element,
21904
- style,
21905
- pseudoState,
21906
- pseudoNamedStyles,
21907
- preventInitialTransition,
21908
- ) => {
21909
- if (!element) {
21910
- return;
21911
- }
21912
- const styleToApply = getStyleToApply(style, pseudoState, pseudoNamedStyles);
21913
- // The same object comes back for every state change of a box whose inline
21914
- // style has no pseudo entry: nothing to write.
21915
- if (appliedStyleWeakMap.get(element) === styleToApply) {
21916
- return;
21917
- }
21918
- updateStyle(element, styleToApply, preventInitialTransition);
21919
- };
21920
-
21921
- const PSEUDO_STATE_DEFAULT = {};
21922
- const PSEUDO_NAMED_STYLES_DEFAULT = {};
21923
- const getStyleToApply = (styles, pseudoState, pseudoNamedStyles) => {
21924
- if (
21925
- !pseudoState ||
21926
- pseudoState === PSEUDO_STATE_DEFAULT ||
21927
- !pseudoNamedStyles ||
21928
- pseudoNamedStyles === PSEUDO_NAMED_STYLES_DEFAULT
21929
- ) {
21930
- return styles;
21931
- }
21932
- let style = styles;
21933
- for (const pseudoKey of Object.keys(pseudoNamedStyles)) {
21934
- if (!hasEveryState(pseudoState, getPseudoKeyRequiredStates(pseudoKey))) {
21935
- continue;
21936
- }
21937
- if (style === styles) {
21938
- style = { ...styles };
21939
- }
21940
- // Both sides are already normalized for CSS by the box; only the
21941
- // properties that compose (a press scale on top of a translate) go through
21942
- // a merge, the rest is a plain override.
21943
- const styleToAdd = pseudoNamedStyles[pseudoKey];
21944
- for (const key of Object.keys(styleToAdd)) {
21945
- const value = styleToAdd[key];
21946
- if (value === undefined) {
21947
- continue;
21948
- }
21949
- if (key === "transform" || key === "willChange") {
21950
- style[key] = mergeOneStyle(style[key], value, key, "css");
21951
- } else {
21952
- style[key] = value;
21953
- }
21954
- }
21955
- }
21956
- return style;
21957
- };
21958
-
21959
- // The state names a pseudo key asks for, parsed once: the same few keys come
21960
- // back on every state change of every box that has them. "::x" alone always
21961
- // matches; "::x:a:b" and ":a:b" ask for ":a" and ":b" — the state keys as
21962
- // checkPseudoClasses writes them, colon included.
21963
- const pseudoKeyRequiredStatesMap = new Map();
21964
- const getPseudoKeyRequiredStates = (pseudoKey) => {
21965
- const cached = pseudoKeyRequiredStatesMap.get(pseudoKey);
21966
- if (cached) {
21967
- return cached;
21968
- }
21969
- let requiredStates;
21970
- if (pseudoKey.startsWith("::")) {
21971
- const nextColonIndex = pseudoKey.indexOf(":", 2);
21972
- requiredStates =
21973
- nextColonIndex === -1
21974
- ? []
21975
- : getPseudoKeyRequiredStates(pseudoKey.slice(nextColonIndex));
21976
- } else {
21977
- const nextColonIndex = pseudoKey.indexOf(":", 1);
21978
- requiredStates =
21979
- nextColonIndex === -1
21980
- ? [pseudoKey]
21981
- : pseudoKey
21982
- .slice(1)
21983
- .split(":")
21984
- .map((state) => `:${state}`);
21985
- }
21986
- pseudoKeyRequiredStatesMap.set(pseudoKey, requiredStates);
21987
- return requiredStates;
21988
- };
21989
- const hasEveryState = (pseudoState, states) => {
21990
- for (const state of states) {
21991
- if (!pseudoState[state]) {
21992
- return false;
21993
- }
21994
- }
21995
- return true;
21996
- };
21997
-
21998
- // element → the style object last written to it, so the next one is written
21999
- // as a difference: the values that changed, the keys it no longer has.
22000
- const appliedStyleWeakMap = new WeakMap();
22001
- const elementTransitionWeakMap = new WeakMap();
22002
- const elementRenderedWeakSet = new WeakSet();
22003
- const NO_STYLE = {};
22004
- const updateStyle = (element, style, preventInitialTransition) => {
22005
- const styleToApply = style || NO_STYLE;
22006
- const styleApplied = appliedStyleWeakMap.get(element) || NO_STYLE;
22007
- // TRANSITION ANTI-FLICKER STRATEGY:
22008
- // Problem: When setting both transition and styled properties simultaneously
22009
- // (e.g., el.style.transition = "border-radius 0.3s ease"; el.style.borderRadius = "20px"),
22010
- // the browser will immediately perform a transition even if no transition existed before.
22011
- //
22012
- // Solution: Temporarily disable transitions during initial style application by setting
22013
- // transition to "none", then restore the intended transition after the frame completes.
22014
- // We handle multiple updateStyle calls in the same frame gracefully - only one
22015
- // requestAnimationFrame is scheduled per element, and the final transition value wins.
22016
- let skipTransition = false;
22017
- if (!elementRenderedWeakSet.has(element)) {
22018
- const hasTransition = Object.hasOwn(styleToApply, "transition");
22019
- if (hasTransition || preventInitialTransition) {
22020
- if (!elementTransitionWeakMap.has(element)) {
22021
- element.style.transition = "none";
22022
- }
22023
- elementTransitionWeakMap.set(element, styleToApply.transition);
22024
- // Stays "none" until the first frame puts the intended value back
22025
- skipTransition = true;
22026
- }
22027
- afterFirstFrame(element);
22028
- }
22029
-
22030
- for (const key of Object.keys(styleToApply)) {
22031
- const value = styleToApply[key];
22032
- if (value === undefined || value === null) {
22033
- // a removal: handled below with the keys this style no longer has
22034
- continue;
22035
- }
22036
- if (skipTransition && key === "transition") {
22037
- continue;
22038
- }
22039
- if (styleApplied[key] === value) {
22040
- continue;
22041
- }
22042
- if (key.startsWith("--")) {
22043
- element.style.setProperty(key, value);
22044
- } else {
22045
- element.style[key] = value;
22046
- }
22047
- }
22048
-
22049
- for (const key of Object.keys(styleApplied)) {
22050
- const previousValue = styleApplied[key];
22051
- if (previousValue === undefined || previousValue === null) {
22052
- continue;
22053
- }
22054
- const value = styleToApply[key];
22055
- if (value !== undefined && value !== null) {
22056
- continue;
22057
- }
22058
- if (key.startsWith("--")) {
22059
- element.style.removeProperty(key);
22060
- } else {
22061
- element.style[key] = "";
22062
- }
22063
- }
22064
-
22065
- appliedStyleWeakMap.set(element, styleToApply);
22066
- };
22067
-
22068
- // One frame for every element waiting for its first one, not one frame each.
22069
- // What this owes each element — put back the transition suppressed above, and
22070
- // remember it has now been painted once — is a few microseconds, while asking
22071
- // the browser for a frame costs more than that, and a page mounting thousands of
22072
- // boxes asks thousands of times in the same tick. A Set, so an element styled
22073
- // twice before the frame arrives is still one entry.
22074
- let elementSetWaitingFirstFrame = new Set();
22075
- let firstFrameScheduled = false;
22076
- const afterFirstFrame = (element) => {
22077
- elementSetWaitingFirstFrame.add(element);
22078
- if (firstFrameScheduled) {
22079
- return;
22080
- }
22081
- firstFrameScheduled = true;
22082
- requestAnimationFrame(() => {
22083
- firstFrameScheduled = false;
22084
- const elements = elementSetWaitingFirstFrame;
22085
- elementSetWaitingFirstFrame = new Set();
22086
- for (const element of elements) {
22087
- if (elementTransitionWeakMap.has(element)) {
22088
- const transitionToRestore = elementTransitionWeakMap.get(element);
22089
- if (transitionToRestore === undefined) {
22090
- element.style.transition = "";
22091
- } else {
22092
- element.style.transition = transitionToRestore;
22093
- }
22094
- elementTransitionWeakMap.delete(element);
22095
- }
22096
- elementRenderedWeakSet.add(element);
22097
- }
22098
- });
22099
- };
22100
-
22101
19414
  /**
22102
19415
  * Keeps a DOM element in sync with `syncElement(el)` whenever syncElement changes.
22103
19416
  * - If element is already mounted: runs syncElement immediately during render.
@@ -33011,8 +30324,6 @@ const RouteTravel = ({
33011
30324
  // and read back as "the page changed" it would start a second travel nobody
33012
30325
  // asked for, over pictures that are already showing something else.
33013
30326
  const pageAskedForRef = useRef(null);
33014
- // What a press stopped in flight, until the gesture says what it is about.
33015
- const caughtAtPressRef = useRef(null);
33016
30327
  // The latest way to answer a gesture, for a watcher that outlives every
33017
30328
  // render (see the wheel effect below).
33018
30329
  const travelHandlersRef = useRef(null);
@@ -33448,6 +30759,23 @@ const RouteTravel = ({
33448
30759
  }
33449
30760
  redirectTravel(travel, page, toIndex > fromIndex ? "forward" : "back");
33450
30761
  };
30762
+
30763
+ // One page further than the travel in flight is going, the same way: what
30764
+ // a second swipe asks for over a page still arriving. Reached the way a tab
30765
+ // pressed while it plays reaches it — the same pictures, the router pointed
30766
+ // one page further (see redirectTravel) — so nothing restarts and nothing
30767
+ // stops. Past the last page there is nothing to aim at, and the travel
30768
+ // simply arrives where it was going.
30769
+ const aimOnePageFurther = (travel, sign) => {
30770
+ const fromIndex = pageIndexOf(pages, travel.page);
30771
+ const page = sign > 0 ? pages[fromIndex - 1] : pages[fromIndex + 1];
30772
+ if (fromIndex === -1 || !page) {
30773
+ return;
30774
+ }
30775
+ redirectTravel(travel, page, travel.direction);
30776
+ pageAskedForRef.current = page;
30777
+ travelTo(page, "drag");
30778
+ };
33451
30779
  const endTravel = travel => {
33452
30780
  if (travel.ended) {
33453
30781
  return;
@@ -33518,7 +30846,8 @@ const RouteTravel = ({
33518
30846
  travelOn: false
33519
30847
  };
33520
30848
  }
33521
- caughtAtPressRef.current = null;
30849
+ // Stopped here, where it stands now: left playing under the press
30850
+ // until the hand said it holds it (see inFlight in onPointerDown).
33522
30851
  holdTravel(travelInFlight);
33523
30852
  // Where the pictures stand right now, said as a pull: what the finger
33524
30853
  // continues from, so nothing jumps when it takes them over.
@@ -33586,11 +30915,18 @@ const RouteTravel = ({
33586
30915
  thrown
33587
30916
  }) => {
33588
30917
  const travel = travelRef.current;
33589
- if (
33590
- // Thrown on before its end: handing over from there would mean jumping
33591
- // the pictures the rest of the way first (see scrubTravel below), a
33592
- // page seen leaping. The travel caught simply arrives.
33593
- thrown || !travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
30918
+ if (thrown) {
30919
+ // Caught, then thrown on before its end: the page after is reached by
30920
+ // aiming the travel in hand further, and let go of it arrives there.
30921
+ // There is no box to hand over — a new one would mean jumping these
30922
+ // pictures the rest of the way first, a page seen leaping — so none
30923
+ // is answered, and the gesture lets the travel go as it is.
30924
+ if (travel && !travel.noPicture && !travel.ended && !travel.reverting) {
30925
+ aimOnePageFurther(travel, sign);
30926
+ }
30927
+ return false;
30928
+ }
30929
+ if (!travel || travel.noPicture || travel.ended || travel.reverting || !CAN_KEEP_PICTURE) {
33594
30930
  return false;
33595
30931
  }
33596
30932
  // Where the page in hand is coming from, said the way the gesture says
@@ -33654,11 +30990,24 @@ const RouteTravel = ({
33654
30990
  travelOn: sign < 0
33655
30991
  };
33656
30992
  },
30993
+ // The next swipe over a page still arriving: the page after it, and the
30994
+ // travel in flight goes there — the same pictures, the router pointed one
30995
+ // page further, exactly what a tab pressed while it plays does (see
30996
+ // retargetTravel). Nothing is held and nothing restarts.
30997
+ onPushOn: ({
30998
+ sign
30999
+ }) => {
31000
+ const travel = travelRef.current;
31001
+ if (!travel || travel.noPicture || travel.ended || travel.reverting || travel.scrub) {
31002
+ return false;
31003
+ }
31004
+ aimOnePageFurther(travel, sign);
31005
+ return true;
31006
+ },
33657
31007
  onEnd: ({
33658
31008
  travels
33659
31009
  }) => {
33660
31010
  gestureRef.current = null;
33661
- caughtAtPressRef.current = null;
33662
31011
  const travel = travelRef.current;
33663
31012
  if (!travel) {
33664
31013
  // The travel this gesture was holding ended under it. Nothing left to
@@ -33685,39 +31034,30 @@ const RouteTravel = ({
33685
31034
  if (!travelByDrag || gestureRef.current) {
33686
31035
  return;
33687
31036
  }
33688
- // Touching something that is moving STOPS it, right there, before the
33689
- // gesture has said anything about itself. Waiting for the first pixels that
33690
- // decide an axis would let the pages travel on under a finger that has
33691
- // already landed on them, which is the one moment a hand expects to be
33692
- // obeyed without asking. If the press turns out to be nothing, the travel
33693
- // is let go of again and carries on (see onGiveUp).
33694
- const travelToCatch = travelRef.current;
33695
- if (travelToCatch && !travelToCatch.noPicture && !travelToCatch.ended && !travelToCatch.reverting) {
33696
- holdTravel(travelToCatch);
33697
- caughtAtPressRef.current = travelToCatch;
33698
- }
33699
- // A travel already playing is not a reason to refuse the press: a hand
33700
- // reaching for a page that is still sliding is reaching for THAT page, and
33701
- // the gesture takes it over (see onStart).
31037
+ // A travel playing under the press is left playing: a hand landing on a
31038
+ // page still sliding is far more often the next swipe than a catch, and
31039
+ // stopping the pages at the press is what that swipe feels as a stall. The
31040
+ // gesture listens to the hand first and says which it was — the next page
31041
+ // (onPushOn) or this one, held (onStart) — see inFlight in
31042
+ // drag_to_travel.js. Either way the press is not refused: a gesture given
31043
+ // back to the browser is a page that rocks under a travel already moving.
31044
+ const travelInFlight = travelRef.current;
31045
+ const leftPlaying = travelInFlight && !travelInFlight.noPicture && !travelInFlight.ended && !travelInFlight.reverting && !travelInFlight.scrub;
33702
31046
  if (currentIndex === -1) {
33703
31047
  return;
33704
31048
  }
33705
- // A press that never became a gesture: whatever it stopped goes on its way,
33706
- // from where the finger caught it.
33707
31049
  const giveUp = () => {
33708
31050
  gestureRef.current = null;
33709
- const caught = caughtAtPressRef.current;
33710
- caughtAtPressRef.current = null;
33711
- if (caught && !caught.ended) {
33712
- releaseHold(caught);
33713
- }
33714
31051
  };
33715
31052
  const gesture = startDragToTravel(pointerDownEvent, {
33716
31053
  element: elementRef.current,
33717
31054
  axes: axis,
33718
- // Caught in flight: the hand is already in the gesture (see above), so it
33719
- // is answered from its first pixel, on the axis the pages travel.
33720
- immediate: caughtAtPressRef.current ? axis : false,
31055
+ inFlight: leftPlaying ? {
31056
+ axis,
31057
+ // The way a pull bringing in the page on its way would go:
31058
+ // dragging towards the end of the axis brings in what is BEFORE.
31059
+ way: travelInFlight.direction === "back" ? 1 : -1
31060
+ } : false,
33721
31061
  ...travelHandlers,
33722
31062
  onGiveUp: giveUp
33723
31063
  });
@@ -59190,13 +56530,10 @@ const SlideContainer = ({
59190
56530
  });
59191
56531
  };
59192
56532
 
59193
- // A travel that is playing when a gesture arrives is STOPPED where it stands,
59194
- // before the gesture has said anything about itself. Not at the first pixels
59195
- // that decide an axis: over those the slides go on at their own speed under a
59196
- // hand already resting on them, and when the gesture finally takes them they
59197
- // are pinned to a hand moving at a quite different pace — the slide does not
59198
- // jump, it stops dead, which is what one reads as a jolt and as "it got away
59199
- // from me".
56533
+ // A travel that is playing when a gesture takes hold of it is STOPPED where it
56534
+ // stands, at the moment the hand holds it (see readTravelInFlight for when
56535
+ // that is): the track stays exactly under the eye, and the hand answers it
56536
+ // from there.
59200
56537
  // A gesture that turns out to be nothing lets the travel carry on from where
59201
56538
  // it was caught (see onGiveUp).
59202
56539
  const catchTravelInFlight = () => {
@@ -59222,15 +56559,31 @@ const SlideContainer = ({
59222
56559
  to: moving.to
59223
56560
  };
59224
56561
  };
59225
- // Which way a caught travel was going: a hand reaching for something moving
59226
- // has no axis left to decide, and a first pixel of tremor read as one gives
59227
- // the gesture up — and lets go of what it just caught.
59228
- const axisOfCaughtTravel = caught => {
59229
- const boxRect = caught.trackElement.getBoundingClientRect();
59230
- const targetPx = offsetToPx(caught.offsetTarget, boxRect);
59231
- const towardsX = Math.abs(targetPx.x - caught.onScreenPx.x);
59232
- const towardsY = Math.abs(targetPx.y - caught.onScreenPx.y);
59233
- return towardsX >= towardsY ? "x" : "y";
56562
+ // Which way a travel still playing is going, read without touching it: a
56563
+ // hand landing on something moving has no axis left to decide (a first pixel
56564
+ // of tremor read as one gives the gesture up), and which WAY along it the
56565
+ // travel goes is what tells the next swipe from a catch. `way` is the sign a
56566
+ // pull going the same way would have.
56567
+ const readTravelInFlight = () => {
56568
+ const trackElement = trackRef.current;
56569
+ const animation = trackAnimationRef.current;
56570
+ if (!animation || animation.playState !== "running") {
56571
+ return null;
56572
+ }
56573
+ const onScreenPx = trackOffsetPx(trackElement, containerRef.current);
56574
+ const targetPx = offsetToPx(offsetRef.current, trackElement.getBoundingClientRect());
56575
+ const towardsX = targetPx.x - onScreenPx.x;
56576
+ const towardsY = targetPx.y - onScreenPx.y;
56577
+ const axis = Math.abs(towardsX) >= Math.abs(towardsY) ? "x" : "y";
56578
+ const towards = axis === "x" ? towardsX : towardsY;
56579
+ if (Math.abs(towards) < 1) {
56580
+ // On its last pixel: nothing left to catch or to push.
56581
+ return null;
56582
+ }
56583
+ return {
56584
+ axis,
56585
+ way: towards > 0 ? 1 : -1
56586
+ };
59234
56587
  };
59235
56588
 
59236
56589
  // What a travel gesture does to the slides, whoever asked for it: a pointer
@@ -59264,8 +56617,14 @@ const SlideContainer = ({
59264
56617
  onStart: ({
59265
56618
  axis,
59266
56619
  sign,
59267
- target
56620
+ target,
56621
+ caught
59268
56622
  }) => {
56623
+ // A travel left playing under the press, which the hand has now taken
56624
+ // hold of: stopped here, where it stands at this moment.
56625
+ if (caught) {
56626
+ caughtTravel = catchTravelInFlight();
56627
+ }
59269
56628
  // Everything positional is read HERE rather than when the pointer
59270
56629
  // landed: the travel that was playing then may have arrived since, and
59271
56630
  // it is what the slides are doing at the moment the gesture takes them
@@ -59558,16 +56917,40 @@ const SlideContainer = ({
59558
56917
  if (!canStartTravel()) {
59559
56918
  return;
59560
56919
  }
59561
- const caughtTravel = catchTravelInFlight();
59562
- const caughtAxis = caughtTravel ? axisOfCaughtTravel(caughtTravel) : null;
56920
+ const travelInFlight = readTravelInFlight();
56921
+ // Left playing when the hand can take it: the press is then far more
56922
+ // likely the next swipe than a catch, and stopping the travel under it is
56923
+ // what that swipe feels as a stall — the gesture listens to the hand first
56924
+ // and says which it was (see inFlight in drag_to_travel.js). On an axis the
56925
+ // hand cannot drag, it is caught at the press, as a travel under a finger
56926
+ // that starts something else.
56927
+ const leftPlaying = Boolean(travelInFlight && dragAxes.includes(travelInFlight.axis));
56928
+ const caughtTravel = travelInFlight && !leftPlaying ? catchTravelInFlight() : null;
59563
56929
  const handlers = createTravelHandlers(caughtTravel);
59564
56930
  const gesture = startDragToTravel(pointerDownEvent, {
59565
56931
  element: containerRef.current,
59566
56932
  axes: dragAxes,
59567
- // Caught in flight: the hand is already in the gesture, so it is answered
59568
- // from its first pixel rather than after a threshold it has no reason to
59569
- // cross twice — on the axis what it caught is travelling on.
59570
- immediate: caughtAxis && dragAxes.includes(caughtAxis) ? caughtAxis : false,
56933
+ inFlight: leftPlaying ? travelInFlight : false,
56934
+ // The next swipe over a travel still playing: one slide further, and the
56935
+ // travel goes on there from where it is — the way a second arrow press
56936
+ // sends it — without ever having stopped under the hand.
56937
+ onPushOn: ({
56938
+ axis,
56939
+ sign,
56940
+ event
56941
+ }) => {
56942
+ if (trackAnimationRef.current?.playState !== "running") {
56943
+ return false;
56944
+ }
56945
+ // Nothing is held: the box is free for the travel that follows.
56946
+ dragRef.current = null;
56947
+ if (axis === "x") {
56948
+ move(-sign, 0, event);
56949
+ } else {
56950
+ move(0, -sign, event);
56951
+ }
56952
+ return true;
56953
+ },
59571
56954
  ...handlers
59572
56955
  });
59573
56956
  if (!gesture) {
@@ -70299,25 +67682,12 @@ const ListUI = props => {
70299
67682
  });
70300
67683
  } else if (loading && loadingFallback) {
70301
67684
  if (loadingFallback === "skeleton") {
70302
- // Skeleton rows draw their own separators: they never reach ListItemUI
70303
- // (where real items get theirs from SeparatorContext), and without them
70304
- // the list would visibly gain its dividers only once loaded.
70305
- const skeletons = [];
70306
- let skeletonIndex = 0;
70307
- while (skeletonIndex < loadingSkeletonCount) {
70308
- if (separator && skeletonIndex > 0) {
70309
- skeletons.push(cloneElement(resolveSeparatorVnode(separator, skeletonIndex - 1), {
70310
- key: `navi-list-skeleton-separator-${skeletonIndex}`
70311
- }));
70312
- }
70313
- skeletons.push(jsx(Fragment$1, {
70314
- children: renderSkeleton ? renderSkeleton(skeletonIndex) : jsx(ListItem, {
70315
- skeleton: true
70316
- })
70317
- }, `navi-list-skeleton-${skeletonIndex}`));
70318
- skeletonIndex++;
70319
- }
70320
- content = skeletons;
67685
+ // Held to the render budget like the rows they stand for: a list
67686
+ // expecting a hundred rows draws the ones its window frames, and the
67687
+ // room of the others is there, so nothing moves when they arrive.
67688
+ content = jsx(ListLoadingRows, {
67689
+ count: loadingSkeletonCount
67690
+ }, "navi-list-loading-rows");
70321
67691
  } else if (loadingFallback === "loader") {
70322
67692
  content = jsx(ListItem, {
70323
67693
  role: "presentation",
@@ -73064,18 +70434,59 @@ const VISIBILITY_HIDDEN_STYLE = {
73064
70434
  * unmounted while asking says `busy: false` on its way out.
73065
70435
  */
73066
70436
  const ListItems = ({
73067
- renderItem,
73068
70437
  items,
73069
70438
  itemsAction,
73070
70439
  count,
73071
- pageSize,
73072
70440
  memoryBudget,
70441
+ onRequestStateChange,
70442
+ ...rowProps
70443
+ }) => {
70444
+ const store = useItemStore({
70445
+ items,
70446
+ count,
70447
+ itemsAction,
70448
+ memoryBudget,
70449
+ onRequestStateChange
70450
+ });
70451
+ return useRunRows(store, rowProps);
70452
+ };
70453
+
70454
+ // The rows a `loading` list is told to expect (loadingSkeletonCount): a run
70455
+ // holding none of them, and asking for none — they arrive through the app.
70456
+ // Drawn as a run's missing rows are, under the render window with fillers
70457
+ // holding the room of the rest, since that is what they stand for.
70458
+ const ListLoadingRows = ({
70459
+ count
70460
+ }) => {
70461
+ return useRunRows(createStoreHoldingNothing(count), LOADING_ROWS_PROPS);
70462
+ };
70463
+ const LOADING_ROWS_PROPS = {};
70464
+ const createStoreHoldingNothing = rowCount => {
70465
+ return {
70466
+ rowCount,
70467
+ failure: null,
70468
+ refreshing: false,
70469
+ forget: () => {},
70470
+ retry: () => {},
70471
+ getItem: () => undefined,
70472
+ eachHeld: () => {},
70473
+ holds: () => false,
70474
+ useRequestMissing: () => {}
70475
+ };
70476
+ };
70477
+
70478
+ // What a run draws: the rows its window frames, a skeleton for each one the
70479
+ // store does not hold, and fillers holding the room of the rows outside it.
70480
+ // Which rows are held, and fetching the others, is the store's (see
70481
+ // useItemStore).
70482
+ const useRunRows = (store, {
70483
+ renderItem,
70484
+ pageSize,
73073
70485
  groupBy,
73074
70486
  renderGroupLabel,
73075
70487
  groupLabelProps,
73076
70488
  renderSkeleton,
73077
- renderError,
73078
- onRequestStateChange
70489
+ renderError
73079
70490
  }) => {
73080
70491
  const ownerId = useId();
73081
70492
  const listRows = useContext(ListRowsContext);
@@ -73097,13 +70508,6 @@ const ListItems = ({
73097
70508
  };
73098
70509
  }
73099
70510
  const rowVnodesByItem = rowVnodesRef.current.byItem;
73100
- const store = useItemStore({
73101
- items,
73102
- count,
73103
- itemsAction,
73104
- memoryBudget,
73105
- onRequestStateChange
73106
- });
73107
70511
  const renderRowSkeleton = renderSkeleton === undefined ? listRows.renderSkeleton : renderSkeleton;
73108
70512
  // A row on its way takes the room the list reserves for it: anything else
73109
70513
  // and the rows drawn stop short of where the scroll says they are. Read
@@ -74164,13 +71568,16 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
74164
71568
  * `loadingSkeletonCount` placeholder rows (look:
74165
71569
  * `renderSkeleton`), `"loader"` a single centered spinner, and
74166
71570
  * anything else is rendered as-is in a row of its own. A falsy value
74167
- * displays nothing. A list that knows how many rows it will have has no use
74168
- * for this — see `<List.Items count>`, whose not-yet-loaded rows are drawn
74169
- * as skeletons in place, one per row, virtualized like the rest.
71571
+ * displays nothing. A list read a slice at a time has no use for this — see
71572
+ * `<List.Items count>`, whose not-yet-loaded rows are drawn as skeletons in
71573
+ * place, one per row, virtualized like the rest.
74170
71574
  * @param {number} [props.loadingSkeletonCount=3]
74171
- * How many placeholder rows `loadingFallback="skeleton"` draws. `0` says the
74172
- * list is already known to be empty: the empty `fallback` shows right away
74173
- * rather than an empty frame, so nothing moves when the response arrives.
71575
+ * How many rows `loadingFallback="skeleton"` stands for — the number the
71576
+ * answer will hold, so nothing moves when it arrives. Drawn under
71577
+ * `renderBudget` like the rows they stand for: the ones the window frames
71578
+ * are skeletons, the others hold their room. `0` says the list is already
71579
+ * known to be empty: the empty `fallback` shows right away rather than an
71580
+ * empty frame.
74174
71581
  * @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
74175
71582
  * Where the list opens, after which the user owns the scroll — unless it is
74176
71583
  * being come back to (see `scrollResetOnNavigation`). `"end"` is a
@@ -89384,5 +86791,5 @@ const UserSvg = () => jsx("svg", {
89384
86791
  })
89385
86792
  });
89386
86793
 
89387
- 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, 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 };
86794
+ 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 };
89388
86795
  //# sourceMappingURL=jsenv_navi.js.map