@jsenv/navi 0.29.406 → 0.29.408
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dev/jsenv_navi.js +3117 -73
- package/dist/dev/jsenv_navi.js.map +34 -9
- package/dist/dev/jsenv_navi_side_effects.js +5 -2865
- package/dist/dev/jsenv_navi_side_effects.js.map +3 -25
- package/dist/jsenv_navi.js +3029 -73
- package/dist/jsenv_navi.js.map +34 -9
- package/dist/jsenv_navi_side_effects.js +5 -2777
- package/dist/jsenv_navi_side_effects.js.map +3 -25
- package/docs/AI_INSTRUCTIONS.md +5 -4
- package/docs/control_value.md +7 -6
- package/docs/safe_area.md +6 -6
- package/docs/scroll.md +52 -1
- package/package.json +2 -2
package/dist/jsenv_navi.js
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* AI reading this file: read ../docs/AI_INSTRUCTIONS.md for context on
|
|
3
3
|
* using @jsenv/navi as intended.
|
|
4
4
|
*/
|
|
5
|
-
import { installImportMetaCssBuild,
|
|
6
|
-
|
|
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";
|
|
5
|
+
import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
|
|
6
|
+
import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, getScrollContainerSet, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, 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, setVirtualKeyboardOverlaysContent, subscribeVirtualKeyboardGeometryChange, getVirtualKeyboardOverlayHeight } from "@jsenv/dom";
|
|
8
7
|
export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
|
|
9
8
|
import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
|
|
10
9
|
import { options, isValidElement, createContext, render, h, toChildArray, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
|
|
@@ -12,7 +11,7 @@ import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, use
|
|
|
12
11
|
import { humanizeI18n, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
|
|
13
12
|
export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
|
|
14
13
|
import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
|
|
15
|
-
import { TYPE_RULE, durationContainsNaN, compareTwoDurations, durationToSeconds, DISPLAYABLE_RULE, MAX_LINE_BREAKS_RULE, NO_EMOJI_RULE, SINGLE_SPACE_RULE, createValidity, resolveCharClass, getCharClassMessageKey, compileCharClassAnchored, compileCharClass, parseDuration, durationToISOString } from "@jsenv/validity";
|
|
14
|
+
import { CHAR_CLASS_PRESETS, TYPE_RULE, durationContainsNaN, compareTwoDurations, durationToSeconds, DISPLAYABLE_RULE, MAX_LINE_BREAKS_RULE, NO_EMOJI_RULE, SINGLE_SPACE_RULE, createValidity, resolveCharClass, getCharClassMessageKey, compileCharClassAnchored, compileCharClass, parseDuration, durationToISOString } from "@jsenv/validity";
|
|
16
15
|
export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
|
|
17
16
|
import { Suspense, createPortal, forwardRef } from "preact/compat";
|
|
18
17
|
|
|
@@ -1577,6 +1576,27 @@ naviI18n.addAll({
|
|
|
1577
1576
|
},
|
|
1578
1577
|
});
|
|
1579
1578
|
|
|
1579
|
+
const isSignal = (value) => {
|
|
1580
|
+
return getSignalType(value) !== null;
|
|
1581
|
+
};
|
|
1582
|
+
|
|
1583
|
+
const BRAND_SYMBOL = Symbol.for("preact-signals");
|
|
1584
|
+
const getSignalType = (value) => {
|
|
1585
|
+
if (!value || typeof value !== "object") {
|
|
1586
|
+
return null;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
if (value.brand !== BRAND_SYMBOL) {
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (typeof value._fn === "function") {
|
|
1594
|
+
return "computed";
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
return "signal";
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1580
1600
|
/*
|
|
1581
1601
|
* The network policy: one declaration saying whether a request may go out,
|
|
1582
1602
|
* read by the action layer rather than by every callback.
|
|
@@ -4736,6 +4756,1118 @@ const actionRunEffect = (
|
|
|
4736
4756
|
return actionRunnedByThisEffect;
|
|
4737
4757
|
};
|
|
4738
4758
|
|
|
4759
|
+
/**
|
|
4760
|
+
* DOM utilities for navigating the control element hierarchy.
|
|
4761
|
+
*
|
|
4762
|
+
* A control is a self-contained interactive widget. Its DOM structure can be
|
|
4763
|
+
* either flat (host only) or layered (wrapper + host):
|
|
4764
|
+
*
|
|
4765
|
+
* Flat — the element is both the root and the host:
|
|
4766
|
+
* ```html
|
|
4767
|
+
* <button navi-control navi-control-host>Click me</button>
|
|
4768
|
+
* ```
|
|
4769
|
+
*
|
|
4770
|
+
* Layered — a visual wrapper surrounds a native input that is the real host:
|
|
4771
|
+
* ```html
|
|
4772
|
+
* <span navi-control> ← wrapper: root of the control's DOM subtree
|
|
4773
|
+
* <input navi-control-host /> ← host: holds controlProps, value, UI state, constraints
|
|
4774
|
+
* </span>
|
|
4775
|
+
* ```
|
|
4776
|
+
*
|
|
4777
|
+
* Attribute roles:
|
|
4778
|
+
* - `navi-control` boolean, on the wrapper/root; marks the control boundary
|
|
4779
|
+
* - `navi-control-host` boolean, on the host; set automatically by `useInteractiveProps`
|
|
4780
|
+
*
|
|
4781
|
+
* See control_proxy.js for the `navi-control-proxy-for` pattern.
|
|
4782
|
+
*/
|
|
4783
|
+
|
|
4784
|
+
/**
|
|
4785
|
+
* Returns the host element inside `el` — the element that holds the control's
|
|
4786
|
+
* value, UI state, and constraints (i.e. the element onto which
|
|
4787
|
+
* `useInteractiveProps` spreads `controlProps` and its event handlers).
|
|
4788
|
+
*
|
|
4789
|
+
* Returns `null` when `el` is itself the host (no separate wrapper).
|
|
4790
|
+
*/
|
|
4791
|
+
const findControlHost = (el) => {
|
|
4792
|
+
if (el.hasAttribute("navi-control-host")) {
|
|
4793
|
+
return el;
|
|
4794
|
+
}
|
|
4795
|
+
return el.querySelector("[navi-control-host]");
|
|
4796
|
+
};
|
|
4797
|
+
/**
|
|
4798
|
+
* The control this element belongs to, wherever it sits relative to it.
|
|
4799
|
+
*
|
|
4800
|
+
* `findControlHost` above answers for a control's own DOM (itself, or the native
|
|
4801
|
+
* element it wraps). This one is for something that is not part of a control but
|
|
4802
|
+
* has to reach one — an interaction declared on a box (see
|
|
4803
|
+
* interaction/interactions.js), which may be the control, may sit inside it, or
|
|
4804
|
+
* may be a wrapper around exactly one. Nearest wins in that order, and the answer
|
|
4805
|
+
* is null when there is no control the box belongs to: not every box lives in
|
|
4806
|
+
* one, and a box laying out several controls (a row of badges, a toolbar) is
|
|
4807
|
+
* held by none of them — its interactions are its own, and a press on its empty
|
|
4808
|
+
* part is not a press on the first control it happens to contain.
|
|
4809
|
+
*/
|
|
4810
|
+
const findNearestControlHost = (el) => {
|
|
4811
|
+
// Itself, then upwards — the box is inside a button, or is one.
|
|
4812
|
+
const selfOrAncestor = el.closest("[navi-control-host]");
|
|
4813
|
+
if (selfOrAncestor) {
|
|
4814
|
+
return selfOrAncestor;
|
|
4815
|
+
}
|
|
4816
|
+
// …then downwards, only when the box wraps a single control. Counted by
|
|
4817
|
+
// control, not by host: a picker holds the hosts of its popup content, and a
|
|
4818
|
+
// box around that one picker is still around one control.
|
|
4819
|
+
let single = null;
|
|
4820
|
+
for (const host of el.querySelectorAll("[navi-control-host]")) {
|
|
4821
|
+
const controlRoot = host.closest("[navi-control]") || host;
|
|
4822
|
+
const controlAbove = controlRoot.parentElement.closest("[navi-control]");
|
|
4823
|
+
if (controlAbove && el.contains(controlAbove)) {
|
|
4824
|
+
continue;
|
|
4825
|
+
}
|
|
4826
|
+
if (single && single !== controlRoot) {
|
|
4827
|
+
return null;
|
|
4828
|
+
}
|
|
4829
|
+
single = controlRoot;
|
|
4830
|
+
}
|
|
4831
|
+
return single ? findControlHost(single) : null;
|
|
4832
|
+
};
|
|
4833
|
+
const isControlRoot = (el) => {
|
|
4834
|
+
return el.hasAttribute("navi-control");
|
|
4835
|
+
};
|
|
4836
|
+
const isControlHost = (el) => {
|
|
4837
|
+
return el.hasAttribute("navi-control-host");
|
|
4838
|
+
};
|
|
4839
|
+
const isControl = (el) => {
|
|
4840
|
+
return isControlRoot(el) || isControlHost(el);
|
|
4841
|
+
};
|
|
4842
|
+
|
|
4843
|
+
/**
|
|
4844
|
+
* Returns the nearest ancestor of `el` (exclusive of `el`'s own control) that
|
|
4845
|
+
* has a `[data-action]` attribute.
|
|
4846
|
+
*
|
|
4847
|
+
* The search walks up `parentElement` manually (rather than using `.closest()`)
|
|
4848
|
+
* so it can stop at hard boundaries.
|
|
4849
|
+
*
|
|
4850
|
+
* **`[navi-control="picker"]` boundary**: a picker is a hard stop for what is
|
|
4851
|
+
* inside its popup. Elements in there can reach the picker itself, but nothing
|
|
4852
|
+
* above it — an input inside a picker must not submit the form around it. The
|
|
4853
|
+
* picker's own trigger is not inside it: a picker pressed as a whole (a confirm
|
|
4854
|
+
* picker sending the form around it, see picker_confirm.jsx) stands in that
|
|
4855
|
+
* form like any control there, and reaches it.
|
|
4856
|
+
*
|
|
4857
|
+
* ```html
|
|
4858
|
+
* <form data-action="outer"> ← found from the picker's own input,
|
|
4859
|
+
* <button navi-control="picker" data-action="p"> NOT from inside its popup
|
|
4860
|
+
* <input navi-control-host /> ← el (the picker's own input)
|
|
4861
|
+
* <div popover data-picker-content>
|
|
4862
|
+
* <input navi-control-host /> ← el (in picker popover): stops at the picker
|
|
4863
|
+
* </div>
|
|
4864
|
+
* </button>
|
|
4865
|
+
* </form>
|
|
4866
|
+
* ```
|
|
4867
|
+
*/
|
|
4868
|
+
const findClosestControlWithAction = (el) => {
|
|
4869
|
+
let current = el;
|
|
4870
|
+
while (current) {
|
|
4871
|
+
if (current.hasAttribute("data-action")) {
|
|
4872
|
+
return current;
|
|
4873
|
+
}
|
|
4874
|
+
if (isPickerBoundaryFor(current, el)) {
|
|
4875
|
+
return undefined;
|
|
4876
|
+
}
|
|
4877
|
+
current = current.parentElement;
|
|
4878
|
+
}
|
|
4879
|
+
return undefined;
|
|
4880
|
+
};
|
|
4881
|
+
const isPickerBoundaryFor = (candidate, el) => {
|
|
4882
|
+
if (candidate.getAttribute("navi-control") !== "picker") {
|
|
4883
|
+
return false;
|
|
4884
|
+
}
|
|
4885
|
+
const content = el.closest("[data-picker-content]");
|
|
4886
|
+
return Boolean(content) && candidate.contains(content);
|
|
4887
|
+
};
|
|
4888
|
+
|
|
4889
|
+
/**
|
|
4890
|
+
* Returns the closest ancestor control element of `el` — i.e. the nearest
|
|
4891
|
+
* `[navi-control]` element that is not the control `el` belongs to.
|
|
4892
|
+
*
|
|
4893
|
+
* `navi-control` is only on wrapper elements, never on hosts. So
|
|
4894
|
+
* `el.closest("[navi-control]")` from a host returns that host's own wrapper,
|
|
4895
|
+
* and one more `.parentNode.closest("[navi-control]")` reaches a true ancestor:
|
|
4896
|
+
*
|
|
4897
|
+
* ```html
|
|
4898
|
+
* <button navi-control> ← outer control (returned)
|
|
4899
|
+
* <span navi-control> ← inner wrapper (skipped via parentNode)
|
|
4900
|
+
* <input navi-control-host /> ← el
|
|
4901
|
+
* </span>
|
|
4902
|
+
* </button>
|
|
4903
|
+
* ```
|
|
4904
|
+
*/
|
|
4905
|
+
const getParentControl = (el) => {
|
|
4906
|
+
const ownControlRoot = el.closest("[navi-control]");
|
|
4907
|
+
const parentControlRoot = ownControlRoot.parentNode.closest("[navi-control]");
|
|
4908
|
+
return parentControlRoot;
|
|
4909
|
+
};
|
|
4910
|
+
|
|
4911
|
+
/**
|
|
4912
|
+
* Returns the root element of the control that `el` belongs to, or `null` if
|
|
4913
|
+
* `el` is not part of a control.
|
|
4914
|
+
*
|
|
4915
|
+
* Use this when you have an element that may be a host (inner input) and need
|
|
4916
|
+
* the visual boundary of its control — e.g. to anchor a callout, track
|
|
4917
|
+
* mousedown interactions, or measure the control's bounding box.
|
|
4918
|
+
*/
|
|
4919
|
+
const findControlRoot = (el) => {
|
|
4920
|
+
if (el.hasAttribute("navi-control")) {
|
|
4921
|
+
return el;
|
|
4922
|
+
}
|
|
4923
|
+
if (el.hasAttribute("navi-control-host")) {
|
|
4924
|
+
return el.closest("[navi-control]");
|
|
4925
|
+
}
|
|
4926
|
+
return null;
|
|
4927
|
+
};
|
|
4928
|
+
|
|
4929
|
+
/**
|
|
4930
|
+
* Parses a time string into seconds.
|
|
4931
|
+
* Accepts:
|
|
4932
|
+
* - number: returned as-is (already in seconds)
|
|
4933
|
+
* - "HH:MM" string: converted to seconds (e.g. "00:30" → 1800, "01:00" → 3600)
|
|
4934
|
+
* - undefined/null: returned as-is
|
|
4935
|
+
*/
|
|
4936
|
+
const timeStringToSeconds = (timeString) => {
|
|
4937
|
+
if (typeof timeString !== "string") {
|
|
4938
|
+
return timeString;
|
|
4939
|
+
}
|
|
4940
|
+
const colonIndex = timeString.indexOf(":");
|
|
4941
|
+
if (colonIndex === -1) {
|
|
4942
|
+
return Number(timeString);
|
|
4943
|
+
}
|
|
4944
|
+
const hours = parseInt(timeString.slice(0, colonIndex), 10);
|
|
4945
|
+
const minutes = parseInt(timeString.slice(colonIndex + 1), 10);
|
|
4946
|
+
return (hours * 60 + minutes) * 60;
|
|
4947
|
+
};
|
|
4948
|
+
|
|
4949
|
+
const isToday = (value) => {
|
|
4950
|
+
if (!value) {
|
|
4951
|
+
return false;
|
|
4952
|
+
}
|
|
4953
|
+
const now = new Date();
|
|
4954
|
+
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
4955
|
+
if (typeof value === "string") {
|
|
4956
|
+
return value === todayStr;
|
|
4957
|
+
}
|
|
4958
|
+
if (typeof value === "number") {
|
|
4959
|
+
const d = new Date(value);
|
|
4960
|
+
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
4961
|
+
return s === todayStr;
|
|
4962
|
+
}
|
|
4963
|
+
if (value instanceof Date) {
|
|
4964
|
+
const s = `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
4965
|
+
return s === todayStr;
|
|
4966
|
+
}
|
|
4967
|
+
return false;
|
|
4968
|
+
};
|
|
4969
|
+
|
|
4970
|
+
/**
|
|
4971
|
+
* Returns the current time as "HH:MM", with an optional minute offset.
|
|
4972
|
+
*
|
|
4973
|
+
* @param {number} [offsetMinutes=0] - Minutes to add (negative = subtract).
|
|
4974
|
+
* E.g. getNowHours(-5) returns "now minus 5 minutes".
|
|
4975
|
+
*
|
|
4976
|
+
* @example
|
|
4977
|
+
* getNowHours() // "14:30"
|
|
4978
|
+
* getNowHours(-5) // "14:25"
|
|
4979
|
+
*/
|
|
4980
|
+
const getNowHours = (offsetMinutes = 0) => {
|
|
4981
|
+
const now = new Date();
|
|
4982
|
+
const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
|
|
4983
|
+
const clamped =
|
|
4984
|
+
totalMinutes < 0
|
|
4985
|
+
? 0
|
|
4986
|
+
: totalMinutes > 23 * 60 + 59
|
|
4987
|
+
? 23 * 60 + 59
|
|
4988
|
+
: totalMinutes;
|
|
4989
|
+
const h = Math.floor(clamped / 60);
|
|
4990
|
+
const m = clamped % 60;
|
|
4991
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
4992
|
+
};
|
|
4993
|
+
|
|
4994
|
+
/**
|
|
4995
|
+
* Returns the current time rounded up to the nearest step boundary,
|
|
4996
|
+
* with an optional minute offset applied first.
|
|
4997
|
+
*
|
|
4998
|
+
* This is useful to compute a step-aligned `min` for a time picker:
|
|
4999
|
+
* passing it ensures the first available slot is always on a step boundary.
|
|
5000
|
+
*
|
|
5001
|
+
* @param {number} stepMinutes - Step size in minutes (e.g. 30).
|
|
5002
|
+
* @param {number} [offsetMinutes=0] - Minutes to add before rounding (negative = subtract).
|
|
5003
|
+
*
|
|
5004
|
+
* @example
|
|
5005
|
+
* // At 9:32, step 30, offset -5 → raw = 9:27 → ceil to 30 → "09:30"
|
|
5006
|
+
* // At 9:38, step 30, offset -5 → raw = 9:33 → ceil to 30 → "10:00"
|
|
5007
|
+
* getNowHoursRoundedToStep(30, -5)
|
|
5008
|
+
*/
|
|
5009
|
+
const getNowHoursRoundedToStep = (stepMinutes, offsetMinutes = 0) => {
|
|
5010
|
+
const now = new Date();
|
|
5011
|
+
const totalMinutes = now.getHours() * 60 + now.getMinutes() + offsetMinutes;
|
|
5012
|
+
const aligned = Math.ceil(totalMinutes / stepMinutes) * stepMinutes;
|
|
5013
|
+
const clamped =
|
|
5014
|
+
aligned < 0 ? 0 : aligned > 23 * 60 + 59 ? 23 * 60 + 59 : aligned;
|
|
5015
|
+
const h = Math.floor(clamped / 60);
|
|
5016
|
+
const m = clamped % 60;
|
|
5017
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
5018
|
+
};
|
|
5019
|
+
|
|
5020
|
+
/**
|
|
5021
|
+
* "HH:MM" and its two numbers, in both directions — what any control made of an
|
|
5022
|
+
* hour beside a minute (fields, wheels) aggregates to and is placed from. Held
|
|
5023
|
+
* as numbers, written on two digits: how they are shown is each control's own
|
|
5024
|
+
* business.
|
|
5025
|
+
*/
|
|
5026
|
+
const parseTimeParts = (time) => {
|
|
5027
|
+
if (typeof time !== "string") {
|
|
5028
|
+
return null;
|
|
5029
|
+
}
|
|
5030
|
+
const match = /^(\d{1,2}):(\d{1,2})/.exec(time);
|
|
5031
|
+
if (!match) {
|
|
5032
|
+
return null;
|
|
5033
|
+
}
|
|
5034
|
+
return { hour: Number(match[1]), minute: Number(match[2]) };
|
|
5035
|
+
};
|
|
5036
|
+
|
|
5037
|
+
// Half a time is not a time: a control holding one of the two and nothing in
|
|
5038
|
+
// the other has no value at all, and a form has nothing to send about it.
|
|
5039
|
+
const formatTimeParts = (hour, minute) => {
|
|
5040
|
+
if (
|
|
5041
|
+
hour === "" ||
|
|
5042
|
+
hour === undefined ||
|
|
5043
|
+
minute === "" ||
|
|
5044
|
+
minute === undefined
|
|
5045
|
+
) {
|
|
5046
|
+
return undefined;
|
|
5047
|
+
}
|
|
5048
|
+
return `${padTwo$1(hour)}:${padTwo$1(minute)}`;
|
|
5049
|
+
};
|
|
5050
|
+
|
|
5051
|
+
const minutesFromTime$1 = (time) => {
|
|
5052
|
+
const parts = parseTimeParts(time);
|
|
5053
|
+
if (!parts) {
|
|
5054
|
+
return null;
|
|
5055
|
+
}
|
|
5056
|
+
return parts.hour * 60 + parts.minute;
|
|
5057
|
+
};
|
|
5058
|
+
|
|
5059
|
+
// Not folded back into the day: 1440 is "24:00", the end of the day a span can
|
|
5060
|
+
// run into.
|
|
5061
|
+
const timeFromMinutes = (minutes) => {
|
|
5062
|
+
return `${padTwo$1(Math.floor(minutes / 60))}:${padTwo$1(minutes % 60)}`;
|
|
5063
|
+
};
|
|
5064
|
+
|
|
5065
|
+
const padTwo$1 = (value) => String(value).padStart(2, "0");
|
|
5066
|
+
|
|
5067
|
+
// What a bound signal's type says is what its VALUE is (a validity type);
|
|
5068
|
+
// what a control's `type` says is what the control IS. This table is the whole
|
|
5069
|
+
// bridge between the two vocabularies: a value type with a row here asks for
|
|
5070
|
+
// that control, one without a row asks for nothing and the control keeps its
|
|
5071
|
+
// own default. "string" has no row on purpose — it is the value every typeless
|
|
5072
|
+
// control already holds — and a name only one side knows must never cross as
|
|
5073
|
+
// is: a control type nobody implements is a plain text field that writes an
|
|
5074
|
+
// object as "[object Object]" (see warnOnUnknownPickerType in picker.jsx).
|
|
5075
|
+
// Numeric value types map to navi_number rather than the native type="number"
|
|
5076
|
+
// (spinner buttons, poor UX).
|
|
5077
|
+
const CONTROL_TYPE_BY_SIGNAL_TYPE = {
|
|
5078
|
+
boolean: "checkbox",
|
|
5079
|
+
number: "navi_number",
|
|
5080
|
+
integer: "navi_number",
|
|
5081
|
+
float: "navi_number",
|
|
5082
|
+
ratio: "navi_number",
|
|
5083
|
+
longitude: "navi_number",
|
|
5084
|
+
latitude: "navi_number",
|
|
5085
|
+
second: "navi_number",
|
|
5086
|
+
minute: "navi_number",
|
|
5087
|
+
hour: "navi_number",
|
|
5088
|
+
year: "navi_number",
|
|
5089
|
+
percentage: "navi_percentage",
|
|
5090
|
+
date: "date",
|
|
5091
|
+
month: "month",
|
|
5092
|
+
week: "week",
|
|
5093
|
+
time: "time",
|
|
5094
|
+
datetime: "datetime",
|
|
5095
|
+
duration: "duration",
|
|
5096
|
+
color: "color",
|
|
5097
|
+
email: "email",
|
|
5098
|
+
url: "url",
|
|
5099
|
+
array: "array",
|
|
5100
|
+
object: "object",
|
|
5101
|
+
};
|
|
5102
|
+
|
|
5103
|
+
// Conceptual navi types: defaults, plus the host type they resolve to.
|
|
5104
|
+
// `navi-input-type` keeps the type the caller asked for once the host has
|
|
5105
|
+
// become something plainer — it is what says the value is a number (see
|
|
5106
|
+
// isNumberInput), and what lets constraint messages use domain-specific
|
|
5107
|
+
// wording instead of the generic "Ce nombre doit être...".
|
|
5108
|
+
// A type a picker preset draws (navi_time, navi_minute: see picker_preset.jsx)
|
|
5109
|
+
// has no row: resolveInputProps runs before the preset resolver, and a row
|
|
5110
|
+
// would turn the type into its host before the preset resolver reads it. The
|
|
5111
|
+
// preset hands the host type on itself.
|
|
5112
|
+
const NAVI_TYPE_DEFAULTS = {
|
|
5113
|
+
navi_percentage: {
|
|
5114
|
+
"type": "navi_number",
|
|
5115
|
+
"navi-input-type": "percentage",
|
|
5116
|
+
"min": 0,
|
|
5117
|
+
"max": 100,
|
|
5118
|
+
"step": 1,
|
|
5119
|
+
},
|
|
5120
|
+
navi_number: {
|
|
5121
|
+
"type": "text",
|
|
5122
|
+
"navi-input-type": "number",
|
|
5123
|
+
"autoCorrect": "off",
|
|
5124
|
+
"spellcheck": false,
|
|
5125
|
+
"autoComplete": "off",
|
|
5126
|
+
},
|
|
5127
|
+
};
|
|
5128
|
+
|
|
5129
|
+
// The navi input types whose value IS a number.
|
|
5130
|
+
const NUMBER_NAVI_INPUT_TYPE_SET = new Set([
|
|
5131
|
+
"number",
|
|
5132
|
+
"percentage",
|
|
5133
|
+
"hour",
|
|
5134
|
+
"minute",
|
|
5135
|
+
"second",
|
|
5136
|
+
]);
|
|
5137
|
+
|
|
5138
|
+
/**
|
|
5139
|
+
* Whether the control holds a number — asked of what the control IS, never of
|
|
5140
|
+
* `inputMode`. In HTML `inputmode` picks the on-screen keyboard and says
|
|
5141
|
+
* nothing about the value: a licence number, a postal code or a card number
|
|
5142
|
+
* with a check letter all want the digit keypad while staying strings. What a
|
|
5143
|
+
* number field is spelled `type="number"`, or a navi type that resolves to a
|
|
5144
|
+
* text host and leaves `navi-input-type` behind to say what it was.
|
|
5145
|
+
*/
|
|
5146
|
+
const isNumberInput = (type, naviInputType) => {
|
|
5147
|
+
if (type === "number") {
|
|
5148
|
+
return true;
|
|
5149
|
+
}
|
|
5150
|
+
return NUMBER_NAVI_INPUT_TYPE_SET.has(naviInputType);
|
|
5151
|
+
};
|
|
5152
|
+
|
|
5153
|
+
/**
|
|
5154
|
+
* resolveInputProps — normalizes input-related props that are shared across
|
|
5155
|
+
* `<Picker>`, `<Input>` (textual) and `<Range>`. Mutates the props object in place.
|
|
5156
|
+
*
|
|
5157
|
+
* Normalization is applied recursively: a navi type may resolve to another navi
|
|
5158
|
+
* type (e.g. `navi_percentage` → `navi_number` → `text`), and each step applies its
|
|
5159
|
+
* own formatters and defaults before moving to the next.
|
|
5160
|
+
*
|
|
5161
|
+
* Steps applied for each type:
|
|
5162
|
+
* 1. Record the original navi type in `props["navi-input-type"]` (first call only).
|
|
5163
|
+
* 2. Apply defaults for the current type (min, max, step, and any other props),
|
|
5164
|
+
* only when the prop is not already set.
|
|
5165
|
+
* 3. Apply min/max formatters (e.g. HH:MM string → number for duration types,
|
|
5166
|
+
* Date → formatted string for date/time types).
|
|
5167
|
+
* 4. Apply step formatter (same conversion rules).
|
|
5168
|
+
* 5. Remap `props.type` to the target type defined by the current type's defaults,
|
|
5169
|
+
* then recurse.
|
|
5170
|
+
*
|
|
5171
|
+
* Supported navi types and their targets:
|
|
5172
|
+
* - `navi_percentage` → `navi_number` (0–100, step 1)
|
|
5173
|
+
* - `navi_number` → `text` (inputMode="numeric", no spin buttons implied)
|
|
5174
|
+
*
|
|
5175
|
+
* Standard HTML input types with formatters:
|
|
5176
|
+
* - `date`, `month`, `week`, `time`, `datetime-local`, `datetime`:
|
|
5177
|
+
* min/max accept `Date` instances or timestamps and are converted to the
|
|
5178
|
+
* string format expected by the native input.
|
|
5179
|
+
* - `time`, `datetime-local`, `datetime`:
|
|
5180
|
+
* step accepts `"HH:MM"` and is converted to seconds.
|
|
5181
|
+
*/
|
|
5182
|
+
/**
|
|
5183
|
+
* A bound signal that carries a default of its own says the same thing on every
|
|
5184
|
+
* control: the control starts there, uncontrolled, and a reset goes back to it.
|
|
5185
|
+
* Uncontrolled here is about where the control starts, not about whether it
|
|
5186
|
+
* follows the signal — the binding stays two-way either way (see
|
|
5187
|
+
* stateFromSignal in control_hooks.jsx) — nor about what a form counts as
|
|
5188
|
+
* held: that is the signal's content (isUIStateHeld in held_ui_state.js), and a
|
|
5189
|
+
* stateSignal holds its default. Written once and used by everything
|
|
5190
|
+
* that takes a `signal`, so one signal cannot mean two different things
|
|
5191
|
+
* depending on which control it was handed to.
|
|
5192
|
+
*/
|
|
5193
|
+
const seedDefaultValueFromSignal = (props) => {
|
|
5194
|
+
const signalOptions = props.signal?.options;
|
|
5195
|
+
if (!signalOptions) {
|
|
5196
|
+
return;
|
|
5197
|
+
}
|
|
5198
|
+
if (Object.hasOwn(props, "defaultValue")) {
|
|
5199
|
+
// explicit defaultValue prop prevails
|
|
5200
|
+
return;
|
|
5201
|
+
}
|
|
5202
|
+
// Snapshot the signal's current default so that resetUIState restores to the
|
|
5203
|
+
// original default — not the value the signal had at the time of the last
|
|
5204
|
+
// re-render.
|
|
5205
|
+
const defaultValue = signalOptions.getDefaultValue(false);
|
|
5206
|
+
if (defaultValue !== undefined) {
|
|
5207
|
+
props.defaultValue = defaultValue;
|
|
5208
|
+
}
|
|
5209
|
+
};
|
|
5210
|
+
|
|
5211
|
+
const resolveInputProps = (props, { controlType = "input" } = {}) => {
|
|
5212
|
+
// `signal` carries a bound state signal. It is left on `props` on purpose:
|
|
5213
|
+
// `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
|
|
5214
|
+
// follow it, and `onUIAction` (ui_state_controller.js) writes user
|
|
5215
|
+
// interactions back into it. Here we only derive input defaults (type/min/max,
|
|
5216
|
+
// defaultValue/defaultChecked) from the signal's `options`, so the control
|
|
5217
|
+
// ends up uncontrolled-with-default while still bound to the signal.
|
|
5218
|
+
const signal = props.signal;
|
|
5219
|
+
if (signal) {
|
|
5220
|
+
const signalOptions = signal.options;
|
|
5221
|
+
if (signalOptions) {
|
|
5222
|
+
for (const key of ["min", "max", "step"]) {
|
|
5223
|
+
if (props[key] === undefined && signalOptions[key] !== undefined) {
|
|
5224
|
+
props[key] = signalOptions[key];
|
|
5225
|
+
}
|
|
5226
|
+
}
|
|
5227
|
+
if (props.type === undefined && signalOptions.type !== undefined) {
|
|
5228
|
+
const typeFromSignal = CONTROL_TYPE_BY_SIGNAL_TYPE[signalOptions.type];
|
|
5229
|
+
// A value type and the control for it usually agree — a date-typed
|
|
5230
|
+
// signal wants a date field — but a boolean one maps to a checkbox, and
|
|
5231
|
+
// a picker made into a checkbox is not a picker with a different look:
|
|
5232
|
+
// it is another control, with no popup to open. A picker asked to hold
|
|
5233
|
+
// a yes/no keeps its two rows and stays itself.
|
|
5234
|
+
const wouldChangeWhatTheControlIs =
|
|
5235
|
+
controlType === "picker" &&
|
|
5236
|
+
(typeFromSignal === "checkbox" || typeFromSignal === "radio");
|
|
5237
|
+
if (typeFromSignal !== undefined && !wouldChangeWhatTheControlIs) {
|
|
5238
|
+
props.type = typeFromSignal;
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5243
|
+
const isCheckable = props.type === "checkbox" || props.type === "radio";
|
|
5244
|
+
if (isCheckable) {
|
|
5245
|
+
if (Object.hasOwn(props, "defaultChecked")) ; else {
|
|
5246
|
+
// If no explicit defaultChecked, derive it from the signal's default
|
|
5247
|
+
// value so that resetUIState restores to the original default.
|
|
5248
|
+
// Only a stateSignal carries a default of its own; a plain signal has
|
|
5249
|
+
// no `options` at all, hence the optional read.
|
|
5250
|
+
const defaultVal = signalOptions?.getDefaultValue(false);
|
|
5251
|
+
if (defaultVal === undefined) ; else if (props.type === "radio") {
|
|
5252
|
+
if (defaultVal === true) {
|
|
5253
|
+
props.defaultChecked = true;
|
|
5254
|
+
} else if (
|
|
5255
|
+
Object.hasOwn(props, "value") &&
|
|
5256
|
+
defaultVal === props.value
|
|
5257
|
+
) {
|
|
5258
|
+
props.defaultChecked = true;
|
|
5259
|
+
}
|
|
5260
|
+
} else if (typeof defaultVal === "boolean") {
|
|
5261
|
+
// Standalone checkbox bound to a boolean signal.
|
|
5262
|
+
props.defaultChecked = defaultVal;
|
|
5263
|
+
} else {
|
|
5264
|
+
// Checkbox is a group member: defaultVal is the array of
|
|
5265
|
+
// selected item values.
|
|
5266
|
+
const checkboxValue = props.value;
|
|
5267
|
+
props.defaultChecked =
|
|
5268
|
+
Array.isArray(defaultVal) && defaultVal.includes(checkboxValue);
|
|
5269
|
+
}
|
|
5270
|
+
}
|
|
5271
|
+
return;
|
|
5272
|
+
}
|
|
5273
|
+
|
|
5274
|
+
seedDefaultValueFromSignal(props);
|
|
5275
|
+
}
|
|
5276
|
+
|
|
5277
|
+
const currentType = props.type;
|
|
5278
|
+
// Apply min/max/step formatters before anything else — this must run even for
|
|
5279
|
+
// standard HTML types (date, time, etc.) that have no NAVI_TYPE_DEFAULTS entry.
|
|
5280
|
+
const currentTypeMinMaxFormatter = MIN_MAX_FORMATTER_BY_TYPE[currentType];
|
|
5281
|
+
const currentTypeStepFormatter = STEP_FORMATTER_BY_TYPE[currentType];
|
|
5282
|
+
if (currentTypeMinMaxFormatter) {
|
|
5283
|
+
props.min = currentTypeMinMaxFormatter(props.min);
|
|
5284
|
+
props.max = currentTypeMinMaxFormatter(props.max);
|
|
5285
|
+
}
|
|
5286
|
+
if (currentTypeStepFormatter) {
|
|
5287
|
+
props.step = currentTypeStepFormatter(props.step);
|
|
5288
|
+
}
|
|
5289
|
+
|
|
5290
|
+
// For navi_number: choose inputMode based on whether step/min/max suggest decimals.
|
|
5291
|
+
// inputMode="numeric" (integer keyboard) vs "decimal" (keyboard with decimal separator).
|
|
5292
|
+
if (currentType === "navi_number") {
|
|
5293
|
+
if (props.inputMode === undefined) {
|
|
5294
|
+
props.inputMode =
|
|
5295
|
+
hasDecimalPlaces(props.step) ||
|
|
5296
|
+
hasDecimalPlaces(props.min) ||
|
|
5297
|
+
hasDecimalPlaces(props.max)
|
|
5298
|
+
? "decimal"
|
|
5299
|
+
: "numeric";
|
|
5300
|
+
}
|
|
5301
|
+
}
|
|
5302
|
+
|
|
5303
|
+
const { charGuard } = props;
|
|
5304
|
+
if (charGuard) {
|
|
5305
|
+
if (charGuard === true || charGuard === "auto") {
|
|
5306
|
+
// Auto-resolve charGuard from context.
|
|
5307
|
+
let charGuardResolved;
|
|
5308
|
+
const inputMode = props.inputMode;
|
|
5309
|
+
if (inputMode === "numeric") {
|
|
5310
|
+
charGuardResolved = "numeric";
|
|
5311
|
+
} else if (inputMode === "decimal") {
|
|
5312
|
+
charGuardResolved = "decimal";
|
|
5313
|
+
} else if (currentType === "tel") {
|
|
5314
|
+
charGuardResolved = "tel";
|
|
5315
|
+
} else if (currentType === "email") {
|
|
5316
|
+
charGuardResolved = "email";
|
|
5317
|
+
}
|
|
5318
|
+
if (charGuardResolved !== undefined) {
|
|
5319
|
+
props.charGuard = charGuardResolved;
|
|
5320
|
+
}
|
|
5321
|
+
}
|
|
5322
|
+
// charGuard is now resolved: derive inputMode from it if not already set.
|
|
5323
|
+
if (props.inputMode === undefined && props.charGuard) {
|
|
5324
|
+
const autoMode = INPUT_MODE_FROM_CHAR_GUARD[props.charGuard];
|
|
5325
|
+
if (autoMode) {
|
|
5326
|
+
props.inputMode = autoMode;
|
|
5327
|
+
}
|
|
5328
|
+
}
|
|
5329
|
+
// Build pattern from the resolved charGuard (preset name → class, or raw class passthrough).
|
|
5330
|
+
if (props.pattern === undefined && props.charGuard) {
|
|
5331
|
+
const charClass = CHAR_CLASS_PRESETS[props.charGuard] ?? props.charGuard;
|
|
5332
|
+
props.pattern = `${charClass}*`;
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
|
|
5336
|
+
// Compute maxLength from max when inputMode is numeric/decimal.
|
|
5337
|
+
// Done here (after inputMode is set) so controller.props has the resolved value.
|
|
5338
|
+
if (props.maxLength === undefined) {
|
|
5339
|
+
if (props.inputMode === "numeric") {
|
|
5340
|
+
const { min, max } = props;
|
|
5341
|
+
if (max === undefined) ; else {
|
|
5342
|
+
const canBeNegative = min === undefined ? max < 0 : min < 0;
|
|
5343
|
+
const signCharCount = canBeNegative ? 1 : 0;
|
|
5344
|
+
const integerDigitCount = String(Math.floor(Math.abs(max))).length;
|
|
5345
|
+
props.maxLength = signCharCount + integerDigitCount;
|
|
5346
|
+
}
|
|
5347
|
+
} else if (props.inputMode === "decimal") {
|
|
5348
|
+
const { min, max, step } = props;
|
|
5349
|
+
if (max === undefined) ; else if (step === undefined) ; else {
|
|
5350
|
+
const canBeNegative = min === undefined ? max < 0 : min < 0;
|
|
5351
|
+
const signCharCount = canBeNegative ? 1 : 0;
|
|
5352
|
+
const integerDigitCount = String(Math.floor(Math.abs(max))).length;
|
|
5353
|
+
const stepStr = String(step);
|
|
5354
|
+
const dotIndex = stepStr.indexOf(".");
|
|
5355
|
+
// integer step + decimal inputMode is an unusual combo, but we stay consistent:
|
|
5356
|
+
// no decimal part in maxLength since valid values are whole numbers anyway
|
|
5357
|
+
const isIntegerStep = dotIndex === -1;
|
|
5358
|
+
const decimalSignCharCount = isIntegerStep ? 0 : 1;
|
|
5359
|
+
const decimalDigitCount = isIntegerStep
|
|
5360
|
+
? 0
|
|
5361
|
+
: stepStr.length - dotIndex - 1;
|
|
5362
|
+
props.maxLength =
|
|
5363
|
+
signCharCount +
|
|
5364
|
+
integerDigitCount +
|
|
5365
|
+
decimalSignCharCount +
|
|
5366
|
+
decimalDigitCount;
|
|
5367
|
+
}
|
|
5368
|
+
}
|
|
5369
|
+
}
|
|
5370
|
+
|
|
5371
|
+
// Resolve maxLengthGuard boolean/auto → the computed maxLength number.
|
|
5372
|
+
if (props.maxLengthGuard === true || props.maxLengthGuard === "auto") {
|
|
5373
|
+
props.maxLengthGuard =
|
|
5374
|
+
typeof props.maxLength === "number" ? props.maxLength : undefined;
|
|
5375
|
+
}
|
|
5376
|
+
|
|
5377
|
+
const currentTypeDefaults = NAVI_TYPE_DEFAULTS[currentType];
|
|
5378
|
+
if (!currentTypeDefaults) {
|
|
5379
|
+
return;
|
|
5380
|
+
}
|
|
5381
|
+
|
|
5382
|
+
for (const key of Object.keys(currentTypeDefaults)) {
|
|
5383
|
+
if (props[key] === undefined) {
|
|
5384
|
+
props[key] = currentTypeDefaults[key];
|
|
5385
|
+
}
|
|
5386
|
+
}
|
|
5387
|
+
const targetType = currentTypeDefaults.type;
|
|
5388
|
+
props.type = targetType;
|
|
5389
|
+
resolveInputProps(props);
|
|
5390
|
+
};
|
|
5391
|
+
|
|
5392
|
+
// Presets that imply a specific mobile keyboard inputMode.
|
|
5393
|
+
const INPUT_MODE_FROM_CHAR_GUARD = {
|
|
5394
|
+
numeric: "numeric",
|
|
5395
|
+
pin: "numeric",
|
|
5396
|
+
card: "numeric",
|
|
5397
|
+
tel: "tel",
|
|
5398
|
+
decimal: "decimal",
|
|
5399
|
+
};
|
|
5400
|
+
|
|
5401
|
+
const normalizeToDate = (value) => {
|
|
5402
|
+
if (value === undefined || value === null) {
|
|
5403
|
+
return null;
|
|
5404
|
+
}
|
|
5405
|
+
if (typeof value === "number") {
|
|
5406
|
+
return new Date(value);
|
|
5407
|
+
}
|
|
5408
|
+
if (value instanceof Date) {
|
|
5409
|
+
return value;
|
|
5410
|
+
}
|
|
5411
|
+
return null;
|
|
5412
|
+
};
|
|
5413
|
+
|
|
5414
|
+
const toInputDate = (value) => {
|
|
5415
|
+
const date = normalizeToDate(value);
|
|
5416
|
+
if (!date) {
|
|
5417
|
+
return value;
|
|
5418
|
+
}
|
|
5419
|
+
const yyyy = date.getFullYear();
|
|
5420
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
5421
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
5422
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
5423
|
+
};
|
|
5424
|
+
const toInputMonth = (value) => {
|
|
5425
|
+
const date = normalizeToDate(value);
|
|
5426
|
+
if (!date) {
|
|
5427
|
+
return value;
|
|
5428
|
+
}
|
|
5429
|
+
const yyyy = date.getFullYear();
|
|
5430
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
5431
|
+
return `${yyyy}-${mm}`;
|
|
5432
|
+
};
|
|
5433
|
+
const toInputWeek = (value) => {
|
|
5434
|
+
const date = normalizeToDate(value);
|
|
5435
|
+
if (!date) {
|
|
5436
|
+
return value;
|
|
5437
|
+
}
|
|
5438
|
+
// ISO week number
|
|
5439
|
+
const d = new Date(date);
|
|
5440
|
+
d.setHours(0, 0, 0, 0);
|
|
5441
|
+
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
|
5442
|
+
const yearStart = new Date(d.getFullYear(), 0, 4);
|
|
5443
|
+
const week =
|
|
5444
|
+
Math.round(
|
|
5445
|
+
((d - yearStart) / 86400000 - 3 + ((yearStart.getDay() + 6) % 7)) / 7,
|
|
5446
|
+
) + 1;
|
|
5447
|
+
return `${d.getFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
5448
|
+
};
|
|
5449
|
+
const toInputTime = (value) => {
|
|
5450
|
+
const date = normalizeToDate(value);
|
|
5451
|
+
if (!date) {
|
|
5452
|
+
return value;
|
|
5453
|
+
}
|
|
5454
|
+
const hh = String(date.getHours()).padStart(2, "0");
|
|
5455
|
+
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
5456
|
+
return `${hh}:${mm}`;
|
|
5457
|
+
};
|
|
5458
|
+
const toInputDatetime = (value) => {
|
|
5459
|
+
const date = normalizeToDate(value);
|
|
5460
|
+
if (!date) {
|
|
5461
|
+
return value;
|
|
5462
|
+
}
|
|
5463
|
+
const yyyy = date.getFullYear();
|
|
5464
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
5465
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
5466
|
+
const hh = String(date.getHours()).padStart(2, "0");
|
|
5467
|
+
const min = String(date.getMinutes()).padStart(2, "0");
|
|
5468
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
|
|
5469
|
+
};
|
|
5470
|
+
|
|
5471
|
+
const MIN_MAX_FORMATTER_BY_TYPE = {
|
|
5472
|
+
"date": toInputDate,
|
|
5473
|
+
"month": toInputMonth,
|
|
5474
|
+
"week": toInputWeek,
|
|
5475
|
+
"time": toInputTime,
|
|
5476
|
+
"datetime-local": toInputDatetime,
|
|
5477
|
+
"datetime": toInputDatetime,
|
|
5478
|
+
};
|
|
5479
|
+
const STEP_FORMATTER_BY_TYPE = {
|
|
5480
|
+
"time": timeStringToSeconds,
|
|
5481
|
+
"datetime-local": timeStringToSeconds,
|
|
5482
|
+
"datetime": timeStringToSeconds,
|
|
5483
|
+
};
|
|
5484
|
+
|
|
5485
|
+
const hasDecimalPlaces = (value) => {
|
|
5486
|
+
if (value === undefined || value === null) {
|
|
5487
|
+
return false;
|
|
5488
|
+
}
|
|
5489
|
+
const num = Number(value);
|
|
5490
|
+
return !isNaN(num) && !Number.isInteger(num);
|
|
5491
|
+
};
|
|
5492
|
+
|
|
5493
|
+
const dispatchRequestSetUIState = (element, value, detail) => {
|
|
5494
|
+
const controlHost = findControlHost(element) || element;
|
|
5495
|
+
return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
|
|
5496
|
+
...detail,
|
|
5497
|
+
value,
|
|
5498
|
+
});
|
|
5499
|
+
};
|
|
5500
|
+
const dispatchRequestClearUIState = (element, e) => {
|
|
5501
|
+
const controlHost = findControlHost(element) || element;
|
|
5502
|
+
return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
|
|
5503
|
+
event: e,
|
|
5504
|
+
});
|
|
5505
|
+
};
|
|
5506
|
+
const dispatchRequestResetUIState = (element, e) => {
|
|
5507
|
+
const controlHost = findControlHost(element) || element;
|
|
5508
|
+
return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
|
|
5509
|
+
event: e,
|
|
5510
|
+
});
|
|
5511
|
+
};
|
|
5512
|
+
/**
|
|
5513
|
+
* @param {Element} el
|
|
5514
|
+
* @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
|
|
5515
|
+
* Only a button ever answers differently — one with no value of its own
|
|
5516
|
+
* inherits the value of the control around it, which is what makes
|
|
5517
|
+
* `--navi-send` on a form's button be about that form. Something asking what
|
|
5518
|
+
* THIS element says (a travel command reading what the travel is about) wants
|
|
5519
|
+
* the own value and would otherwise be handed the surrounding control's.
|
|
5520
|
+
*/
|
|
5521
|
+
const getUIStateFromElement = (el, { own } = {}) => {
|
|
5522
|
+
let uiState;
|
|
5523
|
+
dispatchInternalCustomEvent(el, "navi_get_ui_state", {
|
|
5524
|
+
own,
|
|
5525
|
+
respondWith: (v) => {
|
|
5526
|
+
uiState = v;
|
|
5527
|
+
},
|
|
5528
|
+
});
|
|
5529
|
+
return uiState;
|
|
5530
|
+
};
|
|
5531
|
+
|
|
5532
|
+
/**
|
|
5533
|
+
* Converts a JS value into the form expected by the browser DOM property for a
|
|
5534
|
+
* given control type/input type combination.
|
|
5535
|
+
*
|
|
5536
|
+
* For example:
|
|
5537
|
+
* - `datetime-local` inputs expect a local datetime string without timezone
|
|
5538
|
+
* - `number`/`range` inputs expect a numeric string or number
|
|
5539
|
+
* - `color` inputs require a non-empty hex string (falls back to `#000000`)
|
|
5540
|
+
* - All other inputs receive the value as-is (undefined → "")
|
|
5541
|
+
*
|
|
5542
|
+
* Returns either the converted value directly, or a converter function when the
|
|
5543
|
+
* conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
|
|
5544
|
+
*
|
|
5545
|
+
* @param {any} value - The JS value to convert.
|
|
5546
|
+
* @param {{ controlType: string, type: string }} options
|
|
5547
|
+
* @returns {any} The DOM-compatible value or a converter function.
|
|
5548
|
+
*/
|
|
5549
|
+
const asControlHostValue = (
|
|
5550
|
+
jsValue,
|
|
5551
|
+
{ controlType, type, naviInputType, pad },
|
|
5552
|
+
) => {
|
|
5553
|
+
if (controlType === "select") {
|
|
5554
|
+
// A select holds one of its options, always a string; holding nothing is
|
|
5555
|
+
// the empty option, which the element spells "".
|
|
5556
|
+
return asInputValue(jsValue);
|
|
5557
|
+
}
|
|
5558
|
+
if (controlType === "input" || controlType === "picker") {
|
|
5559
|
+
if (type === "datetime-local") {
|
|
5560
|
+
return asDatetimeLocalString(jsValue);
|
|
5561
|
+
}
|
|
5562
|
+
if (type === "range" || isNumberInput(type, naviInputType)) {
|
|
5563
|
+
return asNumberString(jsValue, pad);
|
|
5564
|
+
}
|
|
5565
|
+
if (type === "color") {
|
|
5566
|
+
return asColorString(jsValue);
|
|
5567
|
+
}
|
|
5568
|
+
return asInputValue(jsValue);
|
|
5569
|
+
}
|
|
5570
|
+
return jsValue;
|
|
5571
|
+
};
|
|
5572
|
+
// As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
|
|
5573
|
+
// datetime-local does not support timezones
|
|
5574
|
+
const asDatetimeLocalString = (dateTimeString) => {
|
|
5575
|
+
const date = new Date(dateTimeString);
|
|
5576
|
+
if (isNaN(date.getTime())) {
|
|
5577
|
+
return dateTimeString;
|
|
5578
|
+
}
|
|
5579
|
+
const year = date.getFullYear();
|
|
5580
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
5581
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
5582
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
5583
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
5584
|
+
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
5585
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
|
5586
|
+
};
|
|
5587
|
+
// `pad` is how many digits the number is WRITTEN on — an hour is held as 7 and
|
|
5588
|
+
// shown as "07". Held and shown are two things here, the way they are for a
|
|
5589
|
+
// datetime-local above: what the field says is derived from what the control
|
|
5590
|
+
// holds, and reading it back (readNumberFromInput) gives the number again.
|
|
5591
|
+
const asNumberString = (jsValue, pad) => {
|
|
5592
|
+
if (jsValue === undefined) {
|
|
5593
|
+
return "";
|
|
5594
|
+
}
|
|
5595
|
+
if (!pad || jsValue === "" || jsValue === null) {
|
|
5596
|
+
return jsValue;
|
|
5597
|
+
}
|
|
5598
|
+
const number = Number(jsValue);
|
|
5599
|
+
if (Number.isNaN(number)) {
|
|
5600
|
+
return jsValue;
|
|
5601
|
+
}
|
|
5602
|
+
const negative = number < 0;
|
|
5603
|
+
const digits = String(negative ? -number : number).padStart(Number(pad), "0");
|
|
5604
|
+
return negative ? `-${digits}` : digits;
|
|
5605
|
+
};
|
|
5606
|
+
// Browser requires a non-empty value for <input type="color">.
|
|
5607
|
+
// When our logical value is empty we give it #000000 so it doesn't choke.
|
|
5608
|
+
// The UI uses the original (possibly empty) value to show the checkerboard.
|
|
5609
|
+
const asColorString = (jsValue) => {
|
|
5610
|
+
return jsValue || "#000000";
|
|
5611
|
+
};
|
|
5612
|
+
const asInputValue = (jsValue) => {
|
|
5613
|
+
if (jsValue === undefined) {
|
|
5614
|
+
return "";
|
|
5615
|
+
}
|
|
5616
|
+
return jsValue;
|
|
5617
|
+
};
|
|
5618
|
+
|
|
5619
|
+
/**
|
|
5620
|
+
* Reads the current logical JS value from a control host DOM element.
|
|
5621
|
+
*
|
|
5622
|
+
* Handles all navi control host element types:
|
|
5623
|
+
* - `<button>` — reads via `navi_get_value` custom event, falls back to `button.value`
|
|
5624
|
+
* - `<input type="number|range">` — parses as a number, returns `undefined` when empty
|
|
5625
|
+
* - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
|
|
5626
|
+
* via `navi_get_value` custom event (to preserve the original JS type of the value prop)
|
|
5627
|
+
* - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
|
|
5628
|
+
* - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
|
|
5629
|
+
* - All other inputs — returns `input.value` as a string
|
|
5630
|
+
*
|
|
5631
|
+
* @param {HTMLElement} controlHost - The control host DOM element to read from.
|
|
5632
|
+
* @returns {any} The current logical value of the control.
|
|
5633
|
+
*/
|
|
5634
|
+
const readControlValue = (controlHost) => {
|
|
5635
|
+
if (
|
|
5636
|
+
controlHost.tagName === "BUTTON" ||
|
|
5637
|
+
controlHost.getAttribute("role") === "button"
|
|
5638
|
+
) {
|
|
5639
|
+
return readValueFromButton(controlHost);
|
|
5640
|
+
}
|
|
5641
|
+
if (controlHost.tagName === "INPUT") {
|
|
5642
|
+
// important: input.type = "navi_js"; followed by input.type; returns "text"
|
|
5643
|
+
// so use getAttribute
|
|
5644
|
+
const type = controlHost.getAttribute("type");
|
|
5645
|
+
|
|
5646
|
+
if (
|
|
5647
|
+
type === "range" ||
|
|
5648
|
+
isNumberInput(type, controlHost.getAttribute("navi-input-type"))
|
|
5649
|
+
) {
|
|
5650
|
+
return readNumberFromInput(controlHost);
|
|
5651
|
+
}
|
|
5652
|
+
if (type === "color") {
|
|
5653
|
+
return readValueFromControlHost(controlHost);
|
|
5654
|
+
}
|
|
5655
|
+
if (type === "checkbox" || type === "radio") {
|
|
5656
|
+
return readValueFromCheckableInput(controlHost);
|
|
5657
|
+
}
|
|
5658
|
+
if (type === "datetime-local") {
|
|
5659
|
+
return readDatetimeLocalFromInput(controlHost);
|
|
5660
|
+
}
|
|
5661
|
+
if (type === "navi_js") {
|
|
5662
|
+
return getUIStateFromElement(controlHost);
|
|
5663
|
+
}
|
|
5664
|
+
return readValueFromInput(controlHost);
|
|
5665
|
+
}
|
|
5666
|
+
if (controlHost.hasAttribute("navi-control-host")) {
|
|
5667
|
+
// Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
|
|
5668
|
+
return readValueFromControlHost(controlHost);
|
|
5669
|
+
}
|
|
5670
|
+
return readValueFromElement(controlHost);
|
|
5671
|
+
};
|
|
5672
|
+
const readValueFromControlHost = (controlHost) => {
|
|
5673
|
+
return readValueFromNaviCustomEvent(controlHost, controlHost.value);
|
|
5674
|
+
};
|
|
5675
|
+
const readValueFromButton = (button) => {
|
|
5676
|
+
return readValueFromControlHost(button);
|
|
5677
|
+
};
|
|
5678
|
+
const readDatetimeLocalFromInput = (input) => {
|
|
5679
|
+
const localDateTimeString = input.value;
|
|
5680
|
+
if (localDateTimeString === "") {
|
|
5681
|
+
return "";
|
|
5682
|
+
}
|
|
5683
|
+
const localDate = new Date(localDateTimeString);
|
|
5684
|
+
if (isNaN(localDate.getTime())) {
|
|
5685
|
+
return localDateTimeString;
|
|
5686
|
+
}
|
|
5687
|
+
return localDate.toISOString();
|
|
5688
|
+
};
|
|
5689
|
+
const readNumberFromInput = (input) => {
|
|
5690
|
+
const numberString = input.value;
|
|
5691
|
+
if (numberString === "") {
|
|
5692
|
+
return "";
|
|
5693
|
+
}
|
|
5694
|
+
const asNumber = Number(numberString);
|
|
5695
|
+
if (isNaN(asNumber)) {
|
|
5696
|
+
return numberString;
|
|
5697
|
+
}
|
|
5698
|
+
return asNumber;
|
|
5699
|
+
};
|
|
5700
|
+
const readValueFromCheckableInput = (input) => {
|
|
5701
|
+
const checked = input.checked;
|
|
5702
|
+
if (!checked) {
|
|
5703
|
+
return undefined;
|
|
5704
|
+
}
|
|
5705
|
+
return readValueFromControlHost(input);
|
|
5706
|
+
};
|
|
5707
|
+
const readValueFromInput = (input) => {
|
|
5708
|
+
const value = input.value;
|
|
5709
|
+
return value;
|
|
5710
|
+
};
|
|
5711
|
+
const readValueFromElement = (element) => {
|
|
5712
|
+
const value = element.value;
|
|
5713
|
+
return value;
|
|
5714
|
+
};
|
|
5715
|
+
const readValueFromNaviCustomEvent = (field, fallback) => {
|
|
5716
|
+
// prefer the value given as prop (respect original type, browser would convert to string)
|
|
5717
|
+
let responded;
|
|
5718
|
+
let value;
|
|
5719
|
+
dispatchCustomEvent(field, "navi_get_value", {
|
|
5720
|
+
respondWith: (jsValue) => {
|
|
5721
|
+
responded = true;
|
|
5722
|
+
value = jsValue;
|
|
5723
|
+
},
|
|
5724
|
+
});
|
|
5725
|
+
if (responded) {
|
|
5726
|
+
return value;
|
|
5727
|
+
}
|
|
5728
|
+
return fallback;
|
|
5729
|
+
};
|
|
5730
|
+
|
|
5731
|
+
// In-memory registry of all mounted ui state controllers keyed by their id.
|
|
5732
|
+
// Allows direct controller access without dispatching DOM events — used by external
|
|
5733
|
+
// callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
|
|
5734
|
+
const controllersById = new Map();
|
|
5735
|
+
|
|
5736
|
+
// In-memory registry for radio controllers, keyed by input name.
|
|
5737
|
+
// Allows radio sibling unchecking without querying the DOM — necessary when
|
|
5738
|
+
// items are virtualized and their DOM element may not exist at the time.
|
|
5739
|
+
// Form scoping is reproduced by comparing parentUIStateController references.
|
|
5740
|
+
const radioControllersByName = new Map();
|
|
5741
|
+
|
|
5742
|
+
// Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
|
|
5743
|
+
// When a value is an object/array, we store it here and write a reference string to the DOM
|
|
5744
|
+
// instead of "[object Object]". Console-inspectable via window.__navi_js('id').
|
|
5745
|
+
// The controller id is used as key — if the controller has no id, the value is not registered.
|
|
5746
|
+
const naviJsRegistry = new Map();
|
|
5747
|
+
|
|
5748
|
+
const getUIStateControllerById = (id) => controllersById.get(id);
|
|
5749
|
+
const getRadioSiblings = (radioUIStateController) => {
|
|
5750
|
+
const siblings = radioControllersByName.get(radioUIStateController.name);
|
|
5751
|
+
return siblings;
|
|
5752
|
+
};
|
|
5753
|
+
|
|
5754
|
+
const toDomValue = (
|
|
5755
|
+
jsValue,
|
|
5756
|
+
{ controlType, id, type, naviInputType, pad },
|
|
5757
|
+
) => {
|
|
5758
|
+
const domValue = asControlHostValue(jsValue, {
|
|
5759
|
+
controlType,
|
|
5760
|
+
type,
|
|
5761
|
+
naviInputType,
|
|
5762
|
+
pad,
|
|
5763
|
+
});
|
|
5764
|
+
if (isSerializableAsDomValue(domValue)) {
|
|
5765
|
+
return domValue;
|
|
5766
|
+
}
|
|
5767
|
+
naviJsRegistry.set(id, domValue);
|
|
5768
|
+
return `window.__navi_js('${id}')`;
|
|
5769
|
+
};
|
|
5770
|
+
|
|
5771
|
+
window.__navi_js = (id) => naviJsRegistry.get(id);
|
|
5772
|
+
const isSerializableAsDomValue = (value) => {
|
|
5773
|
+
if (value === null || value === undefined) {
|
|
5774
|
+
return true;
|
|
5775
|
+
}
|
|
5776
|
+
const type = typeof value;
|
|
5777
|
+
return type === "string" || type === "number" || type === "boolean";
|
|
5778
|
+
};
|
|
5779
|
+
|
|
5780
|
+
const onUIStateControllerCreated = (uiStateController) => {
|
|
5781
|
+
const { id, name, controlType } = uiStateController;
|
|
5782
|
+
if (id) {
|
|
5783
|
+
controllersById.set(id, uiStateController);
|
|
5784
|
+
}
|
|
5785
|
+
const proxyFor = uiStateController.props["navi-control-proxy-for"];
|
|
5786
|
+
if (proxyFor) {
|
|
5787
|
+
let proxySet = proxyControllersByRealInputId.get(proxyFor);
|
|
5788
|
+
if (!proxySet) {
|
|
5789
|
+
proxySet = new Set();
|
|
5790
|
+
proxyControllersByRealInputId.set(proxyFor, proxySet);
|
|
5791
|
+
}
|
|
5792
|
+
proxySet.add(uiStateController);
|
|
5793
|
+
}
|
|
5794
|
+
if (
|
|
5795
|
+
controlType === "input" &&
|
|
5796
|
+
uiStateController.props.type === "radio" &&
|
|
5797
|
+
name
|
|
5798
|
+
) {
|
|
5799
|
+
let set = radioControllersByName.get(name);
|
|
5800
|
+
if (!set) {
|
|
5801
|
+
set = new Set();
|
|
5802
|
+
radioControllersByName.set(name, set);
|
|
5803
|
+
}
|
|
5804
|
+
set.add(uiStateController);
|
|
5805
|
+
}
|
|
5806
|
+
};
|
|
5807
|
+
const onUIStateControllerDestroyed = (uiStateController) => {
|
|
5808
|
+
const { id, name, controlType } = uiStateController;
|
|
5809
|
+
if (id) {
|
|
5810
|
+
// Only the controller the id currently points at may take the entry away:
|
|
5811
|
+
// when two controls share an id, the one leaving would otherwise unregister
|
|
5812
|
+
// the one staying (see the warning in onUIStateControllerCreated), and the
|
|
5813
|
+
// same holds while a control is being replaced by its successor, which
|
|
5814
|
+
// registers during its render, before this cleanup runs.
|
|
5815
|
+
if (controllersById.get(id) === uiStateController) {
|
|
5816
|
+
controllersById.delete(id);
|
|
5817
|
+
naviJsRegistry.delete(id);
|
|
5818
|
+
}
|
|
5819
|
+
}
|
|
5820
|
+
const proxyFor = uiStateController.props["navi-control-proxy-for"];
|
|
5821
|
+
if (proxyFor) {
|
|
5822
|
+
const proxySet = proxyControllersByRealInputId.get(proxyFor);
|
|
5823
|
+
if (proxySet) {
|
|
5824
|
+
proxySet.delete(uiStateController);
|
|
5825
|
+
if (proxySet.size === 0) {
|
|
5826
|
+
proxyControllersByRealInputId.delete(proxyFor);
|
|
5827
|
+
}
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5830
|
+
if (
|
|
5831
|
+
controlType === "input" &&
|
|
5832
|
+
uiStateController.controlHostProps.type === "radio" &&
|
|
5833
|
+
name
|
|
5834
|
+
) {
|
|
5835
|
+
const set = radioControllersByName.get(name);
|
|
5836
|
+
if (set) {
|
|
5837
|
+
set.delete(uiStateController);
|
|
5838
|
+
if (set.size === 0) {
|
|
5839
|
+
radioControllersByName.delete(name);
|
|
5840
|
+
}
|
|
5841
|
+
}
|
|
5842
|
+
}
|
|
5843
|
+
};
|
|
5844
|
+
|
|
5845
|
+
/**
|
|
5846
|
+
* Controller-based equivalent of findControlProxyTarget.
|
|
5847
|
+
* Given a proxy controller, returns the real control's controller.
|
|
5848
|
+
* Finds the target by walking the parent controller's children — no DOM queries.
|
|
5849
|
+
* Returns `null` when the controller is not a proxy or the target is not found.
|
|
5850
|
+
*/
|
|
5851
|
+
const findControlProxyTargetController = (controller) => {
|
|
5852
|
+
const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
|
|
5853
|
+
if (!proxyFor) {
|
|
5854
|
+
return null;
|
|
5855
|
+
}
|
|
5856
|
+
return getUIStateControllerById(proxyFor) ?? null;
|
|
5857
|
+
};
|
|
5858
|
+
|
|
5859
|
+
// Reverse-lookup map: real-input id → the proxy controllers that reference it
|
|
5860
|
+
// via `navi-control-proxy-for`. A single control can be represented by several
|
|
5861
|
+
// proxies (an "enable"/"disable" button pair for one radio, for instance), so
|
|
5862
|
+
// each id holds a set. Maintained on create/destroy so lookup is O(1).
|
|
5863
|
+
const proxyControllersByRealInputId = new Map();
|
|
5864
|
+
const findProxyControllers = (realInputId) => {
|
|
5865
|
+
if (!realInputId) {
|
|
5866
|
+
return null;
|
|
5867
|
+
}
|
|
5868
|
+
return proxyControllersByRealInputId.get(realInputId) ?? null;
|
|
5869
|
+
};
|
|
5870
|
+
|
|
4739
5871
|
/**
|
|
4740
5872
|
* The attributes constraints read, filled by each constraint module as it
|
|
4741
5873
|
* evaluates. A constraint declares the attribute it wants (`"data-no-emoji"`)
|
|
@@ -5364,6 +6496,113 @@ const isInertOnClick = (element) => {
|
|
|
5364
6496
|
return true;
|
|
5365
6497
|
};
|
|
5366
6498
|
|
|
6499
|
+
/**
|
|
6500
|
+
* DOM utilities for the proxy control pattern.
|
|
6501
|
+
*
|
|
6502
|
+
* Some components need a native `<input>` internally — for form submission,
|
|
6503
|
+
* constraint validation, or browser autofill — but the user may not want to
|
|
6504
|
+
* display that input at all. In those cases the input is hidden and a separate
|
|
6505
|
+
* visible element (the proxy) takes over the visual and interactive role.
|
|
6506
|
+
*
|
|
6507
|
+
* The typical use case is `SelectableList`: each list item acts as a styled
|
|
6508
|
+
* radio button, but an actual `<input type="radio">` lives hidden in the DOM
|
|
6509
|
+
* so form submission and validation work natively. When users DO want to
|
|
6510
|
+
* display the input they want full control over its appearance, so they render
|
|
6511
|
+
* their own element and link it to the real input via `navi-control-proxy-for`:
|
|
6512
|
+
*
|
|
6513
|
+
* ```html
|
|
6514
|
+
* <div>
|
|
6515
|
+
* <input id="color_red" type="radio" name="color" value="red" /> ← real control (hidden, drives form/validation)
|
|
6516
|
+
* <input type="radio" name="proxy" value="red" /> ← proxy (visible, delegates interactions to real input)
|
|
6517
|
+
* </div>
|
|
6518
|
+
* ```
|
|
6519
|
+
*
|
|
6520
|
+
* A press on a checkable proxy is dealt to the real control, and so is the
|
|
6521
|
+
* focus: state management, validation and form submission then all happen
|
|
6522
|
+
* where the value lives. A proxy that says what its press does — a button
|
|
6523
|
+
* carrying `command="--navi-check"` — answers the press itself instead.
|
|
6524
|
+
*
|
|
6525
|
+
* Note: an alternative design would be to require users to always instantiate
|
|
6526
|
+
* the input explicitly — e.g. `<Selectable.Input headless />` when they don't
|
|
6527
|
+
* want to display it. That would remove the need for the proxy mechanism
|
|
6528
|
+
* entirely. For now we keep the proxy pattern.
|
|
6529
|
+
*/
|
|
6530
|
+
|
|
6531
|
+
|
|
6532
|
+
/**
|
|
6533
|
+
* Given a proxy element, returns the real control it represents.
|
|
6534
|
+
* Returns `null` when `el` is not a proxy.
|
|
6535
|
+
*/
|
|
6536
|
+
const findControlProxyTarget = (el) => {
|
|
6537
|
+
const proxyFor = el.getAttribute("navi-control-proxy-for");
|
|
6538
|
+
if (!proxyFor) {
|
|
6539
|
+
return null;
|
|
6540
|
+
}
|
|
6541
|
+
return document.getElementById(proxyFor);
|
|
6542
|
+
};
|
|
6543
|
+
|
|
6544
|
+
// Shared: asked on every pseudo state change of every box, and almost always
|
|
6545
|
+
// empty.
|
|
6546
|
+
const NO_PROXIES = Object.freeze([]);
|
|
6547
|
+
/**
|
|
6548
|
+
* Given a real control element, returns every proxy that visually represents
|
|
6549
|
+
* it — a control can have more than one (an "enable"/"disable" button pair for
|
|
6550
|
+
* one radio, for instance).
|
|
6551
|
+
*
|
|
6552
|
+
* Answered from the controller registry rather than the document: every proxy
|
|
6553
|
+
* declares itself through the `navi-control-proxy-for` prop, so the registry
|
|
6554
|
+
* knows them all, while asking the document means walking it in full for each
|
|
6555
|
+
* of the (overwhelmingly many) controls that have no proxy at all.
|
|
6556
|
+
*
|
|
6557
|
+
* Returns an empty array when no proxy exists for `el`.
|
|
6558
|
+
*/
|
|
6559
|
+
const findControlProxies = (el) => {
|
|
6560
|
+
if (!el.id) {
|
|
6561
|
+
return NO_PROXIES;
|
|
6562
|
+
}
|
|
6563
|
+
const proxyControllerSet = findProxyControllers(el.id);
|
|
6564
|
+
if (!proxyControllerSet) {
|
|
6565
|
+
return NO_PROXIES;
|
|
6566
|
+
}
|
|
6567
|
+
const proxyElements = [];
|
|
6568
|
+
for (const proxyController of proxyControllerSet) {
|
|
6569
|
+
const proxyElement = proxyController.ref.current;
|
|
6570
|
+
if (proxyElement) {
|
|
6571
|
+
proxyElements.push(proxyElement);
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
return proxyElements;
|
|
6575
|
+
};
|
|
6576
|
+
|
|
6577
|
+
/**
|
|
6578
|
+
* Given a real control element, returns the proxy a gesture on the control
|
|
6579
|
+
* lands on — the one thing said about the control is drawn on.
|
|
6580
|
+
*
|
|
6581
|
+
* Use when you need a single visible stand-in for the real control — anchoring
|
|
6582
|
+
* a callout, for instance. Anything notifying proxies of a state change wants
|
|
6583
|
+
* `findControlProxies` instead, so a control represented by several of them
|
|
6584
|
+
* updates all of them.
|
|
6585
|
+
*
|
|
6586
|
+
* A proxy the pointer cannot reach is drawn for the control without standing
|
|
6587
|
+
* for it: the surface around it takes the presses for everything it holds, so
|
|
6588
|
+
* that surface is where a refusal about the control belongs — not a box inside
|
|
6589
|
+
* it the finger never has to find. A selectable list row whose whole area is
|
|
6590
|
+
* the click target is exactly that arrangement (see `selectableArea`), and it
|
|
6591
|
+
* is read from the arrangement itself rather than from a flag repeating it:
|
|
6592
|
+
* where a gesture can land IS pointer-events.
|
|
6593
|
+
*
|
|
6594
|
+
* Returns `null` when no proxy stands for `el`.
|
|
6595
|
+
*/
|
|
6596
|
+
const findControlProxy = (el) => {
|
|
6597
|
+
for (const proxyElement of findControlProxies(el)) {
|
|
6598
|
+
if (getComputedStyle(proxyElement).pointerEvents === "none") {
|
|
6599
|
+
continue;
|
|
6600
|
+
}
|
|
6601
|
+
return proxyElement;
|
|
6602
|
+
}
|
|
6603
|
+
return null;
|
|
6604
|
+
};
|
|
6605
|
+
|
|
5367
6606
|
/**
|
|
5368
6607
|
* Typography reset shared by the surfaces a control opens: callout, popover,
|
|
5369
6608
|
* dialog.
|
|
@@ -11400,7 +12639,8 @@ const useActionBoundToOneParam = (action, paramsSignal) => {
|
|
|
11400
12639
|
}, []);
|
|
11401
12640
|
return [boundAction, getValue(), setValue];
|
|
11402
12641
|
};
|
|
11403
|
-
//
|
|
12642
|
+
// The action bound to the signal, without reading it: the caller is not
|
|
12643
|
+
// re-rendered by the params (a control, a <details>).
|
|
11404
12644
|
const useAction = (action, paramsSignal) => {
|
|
11405
12645
|
return useBoundAction(action, paramsSignal);
|
|
11406
12646
|
};
|
|
@@ -19411,6 +20651,1453 @@ const getDefaultDisplay = (tagName) => {
|
|
|
19411
20651
|
return TAG_NAME_TO_DEFAULT_DISPLAY.get(normalizedTagName) || "inline";
|
|
19412
20652
|
};
|
|
19413
20653
|
|
|
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
|
+
|
|
19414
22101
|
/**
|
|
19415
22102
|
* Keeps a DOM element in sync with `syncElement(el)` whenever syncElement changes.
|
|
19416
22103
|
* - If element is already mounted: runs syncElement immediately during render.
|
|
@@ -19691,7 +22378,7 @@ import.meta.css = /* css */[`[data-scrollable] > .navi_loading_outline_wrapper,
|
|
|
19691
22378
|
}
|
|
19692
22379
|
}
|
|
19693
22380
|
|
|
19694
|
-
[hidden] {
|
|
22381
|
+
[hidden]:not([hidden="until-found" i]) {
|
|
19695
22382
|
display: none !important;
|
|
19696
22383
|
}
|
|
19697
22384
|
`, "@jsenv/navi/src/box/box.jsx"];
|
|
@@ -38854,7 +41541,14 @@ const useControlProps = (props, {
|
|
|
38854
41541
|
persists,
|
|
38855
41542
|
uiActionInternal
|
|
38856
41543
|
});
|
|
38857
|
-
|
|
41544
|
+
// What the control draws is what it holds by itself, and this read is what
|
|
41545
|
+
// re-renders it when that changes. A button inheriting the value of the
|
|
41546
|
+
// control around it (a submit in a form, see ownUIStateSignal) draws none of
|
|
41547
|
+
// that value, it only hands it to its action: subscribed to it, every button
|
|
41548
|
+
// of a form would re-render at each change of any of its fields.
|
|
41549
|
+
// eslint-disable-next-line no-unused-expressions
|
|
41550
|
+
uiStateController.ownUIStateSignal.value;
|
|
41551
|
+
const boundAction = useAction(props.action, uiStateController.uiStateSignal);
|
|
38858
41552
|
const [controlRootProps, controlHostProps] = useInteractiveProps(props, {
|
|
38859
41553
|
uiStateController,
|
|
38860
41554
|
boundAction,
|
|
@@ -39619,7 +42313,10 @@ const useControlProps = (props, {
|
|
|
39619
42313
|
};
|
|
39620
42314
|
}
|
|
39621
42315
|
}
|
|
39622
|
-
|
|
42316
|
+
|
|
42317
|
+
// Its own, like syncDomState: a button's DOM value is not the value it
|
|
42318
|
+
// inherits from the control around it.
|
|
42319
|
+
const uiState = uiStateController.ownUIStateSignal.peek();
|
|
39623
42320
|
const domProps = toDomProps(uiState);
|
|
39624
42321
|
{
|
|
39625
42322
|
// Same as syncDomState: a field being typed into keeps its own text, so a
|
|
@@ -40147,7 +42844,9 @@ const useInteractiveProps = (props, {
|
|
|
40147
42844
|
// stop waiting on a run that is still going. What it waits on is the run
|
|
40148
42845
|
// it started, from navi_action_start to its settlement.
|
|
40149
42846
|
const runningAction = uiStateController.runningActionSignal.value;
|
|
40150
|
-
|
|
42847
|
+
// Only whether it runs: the whole status (useActionStatus) reads the params
|
|
42848
|
+
// too, which for a button inheriting its form's value are the form.
|
|
42849
|
+
const actionRunning = (runningAction || boundAction).runningStateSignal.value === RUNNING;
|
|
40151
42850
|
const networkPolicyReason = useNetworkPolicyReason();
|
|
40152
42851
|
const {
|
|
40153
42852
|
disabled,
|
|
@@ -40195,7 +42894,7 @@ const useInteractiveProps = (props, {
|
|
|
40195
42894
|
// just set stays visible and interactive while the action runs — no
|
|
40196
42895
|
// loading, no readonly. On failure resetOnError rolls the state back and
|
|
40197
42896
|
// the error callout says why.
|
|
40198
|
-
const actionLoading = optimistic ? false :
|
|
42897
|
+
const actionLoading = optimistic ? false : actionRunning;
|
|
40199
42898
|
const loadingResolved = loadingBase || actionLoading;
|
|
40200
42899
|
const readOnlyResolved = readOnlyBase || actionLoading;
|
|
40201
42900
|
// Read-only, and what this control opens still opens: reading what is in
|
|
@@ -40390,7 +43089,11 @@ const useInteractiveProps = (props, {
|
|
|
40390
43089
|
errorEffect: actionErrorEffect,
|
|
40391
43090
|
errorMapping
|
|
40392
43091
|
});
|
|
40393
|
-
|
|
43092
|
+
// What the attribute says is that an action is there (see
|
|
43093
|
+
// findClosestControlWithAction); its text is a label. Peeked (toString),
|
|
43094
|
+
// because `.callSource` follows the params — for a button inheriting its
|
|
43095
|
+
// form's value, a re-render at every change of any field.
|
|
43096
|
+
const dataAction = action === undefined ? undefined : String(boundAction);
|
|
40394
43097
|
Object.assign(controlHostProps, {
|
|
40395
43098
|
"data-action": dataAction
|
|
40396
43099
|
});
|
|
@@ -49307,30 +52010,9 @@ const ButtonUI = props => {
|
|
|
49307
52010
|
} = buttonControlHostProps;
|
|
49308
52011
|
const loading = basePseudoState[":-navi-loading"];
|
|
49309
52012
|
|
|
49310
|
-
//
|
|
49311
|
-
|
|
49312
|
-
const
|
|
49313
|
-
let as = "button";
|
|
49314
|
-
let innerTarget;
|
|
49315
|
-
let innerRel;
|
|
49316
|
-
let innerCurrent;
|
|
49317
|
-
if (isLink) {
|
|
49318
|
-
as = "a";
|
|
49319
|
-
const {
|
|
49320
|
-
isSameSite,
|
|
49321
|
-
isCurrent
|
|
49322
|
-
} = getHrefTargetInfo(href);
|
|
49323
|
-
innerTarget = target === undefined ? isSameSite ? undefined : "_blank" : target;
|
|
49324
|
-
innerRel = rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel;
|
|
49325
|
-
innerCurrent = isCurrent;
|
|
49326
|
-
}
|
|
49327
|
-
// For a button that has only an href: nothing else knows it points at the
|
|
49328
|
-
// page one is on. A route says so through `pseudoState`, which Box lays over
|
|
49329
|
-
// this one.
|
|
49330
|
-
buttonControlHostProps.basePseudoState = {
|
|
49331
|
-
...basePseudoState,
|
|
49332
|
-
":-navi-href-current": innerCurrent
|
|
49333
|
-
};
|
|
52013
|
+
// Where the href leads next to the page one is on (the target, the rel, the
|
|
52014
|
+
// current state) is resolved above, by ButtonHrefResolver.
|
|
52015
|
+
const as = href === undefined ? "button" : "a";
|
|
49334
52016
|
|
|
49335
52017
|
// Worn as an attribute, like a link's (see link_replace.js): read off the
|
|
49336
52018
|
// anchor by the click handler, off the source by --navi-nav-to.
|
|
@@ -49379,8 +52061,8 @@ const ButtonUI = props => {
|
|
|
49379
52061
|
ref: ref,
|
|
49380
52062
|
as: as,
|
|
49381
52063
|
href: href,
|
|
49382
|
-
target:
|
|
49383
|
-
rel:
|
|
52064
|
+
target: target,
|
|
52065
|
+
rel: rel,
|
|
49384
52066
|
replace: undefined,
|
|
49385
52067
|
...replaceRequest,
|
|
49386
52068
|
document: undefined,
|
|
@@ -49680,6 +52362,49 @@ const ButtonConfirm = ({
|
|
|
49680
52362
|
});
|
|
49681
52363
|
};
|
|
49682
52364
|
|
|
52365
|
+
const ButtonHrefResolver = props => {
|
|
52366
|
+
const Next = useNextResolver();
|
|
52367
|
+
if (props.href !== undefined) {
|
|
52368
|
+
return renderResolver(ButtonWithHref, props);
|
|
52369
|
+
}
|
|
52370
|
+
return jsx(Next, {
|
|
52371
|
+
...props
|
|
52372
|
+
});
|
|
52373
|
+
};
|
|
52374
|
+
|
|
52375
|
+
// What the href means next to the page one is on depends on the document url,
|
|
52376
|
+
// so a button with an href re-renders at every address write. One without
|
|
52377
|
+
// never reaches here: it has nothing to re-compute, and every button on screen
|
|
52378
|
+
// would otherwise pay for each write.
|
|
52379
|
+
const ButtonWithHref = props => {
|
|
52380
|
+
const Next = useNextResolver();
|
|
52381
|
+
const {
|
|
52382
|
+
href,
|
|
52383
|
+
target,
|
|
52384
|
+
rel,
|
|
52385
|
+
pseudoState
|
|
52386
|
+
} = props;
|
|
52387
|
+
// Read for the subscription: getHrefTargetInfo reads window.location.
|
|
52388
|
+
useDocumentUrl();
|
|
52389
|
+
const {
|
|
52390
|
+
isSameSite,
|
|
52391
|
+
isCurrent
|
|
52392
|
+
} = getHrefTargetInfo(href);
|
|
52393
|
+
return jsx(Next, {
|
|
52394
|
+
...props,
|
|
52395
|
+
target: target === undefined ? isSameSite ? undefined : "_blank" : target,
|
|
52396
|
+
rel: rel === undefined ? isSameSite ? undefined : "noopener noreferrer" : rel
|
|
52397
|
+
// Under whatever the caller already says: a route knows better whether
|
|
52398
|
+
// it is the page one is on (see button_route.jsx), and a demo can force
|
|
52399
|
+
// the state.
|
|
52400
|
+
,
|
|
52401
|
+
pseudoState: {
|
|
52402
|
+
":-navi-href-current": isCurrent,
|
|
52403
|
+
...pseudoState
|
|
52404
|
+
}
|
|
52405
|
+
});
|
|
52406
|
+
};
|
|
52407
|
+
|
|
49683
52408
|
const ButtonRouteResolver = props => {
|
|
49684
52409
|
const Next = useNextResolver();
|
|
49685
52410
|
if (props.route) {
|
|
@@ -49934,7 +52659,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
|
|
|
49934
52659
|
* (it wears it itself) and loses the shrink under the finger, which has
|
|
49935
52660
|
* nothing left to scale but the interactive area itself.
|
|
49936
52661
|
*/
|
|
49937
|
-
const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonUI]);
|
|
52662
|
+
const Button = /*#__PURE__*/createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonConfirmResolver, ButtonHrefResolver, ButtonUI]);
|
|
49938
52663
|
|
|
49939
52664
|
installImportMetaCssBuild(import.meta);
|
|
49940
52665
|
const css$O = /* css */`.navi_control_swap {
|
|
@@ -67210,12 +69935,28 @@ const css$x = /* css */`@layer navi {
|
|
|
67210
69935
|
display: inline-block;
|
|
67211
69936
|
}
|
|
67212
69937
|
|
|
69938
|
+
.navi_list_find_stand_in {
|
|
69939
|
+
height: calc(var(--x-find-line-count) * var(--x-find-line-size));
|
|
69940
|
+
color: #0000;
|
|
69941
|
+
line-height: var(--x-find-line-size);
|
|
69942
|
+
white-space: pre;
|
|
69943
|
+
overflow: clip;
|
|
69944
|
+
}
|
|
69945
|
+
|
|
67213
69946
|
.navi_list_container[data-horizontal] {
|
|
67214
69947
|
--list-max-height: none;
|
|
67215
69948
|
|
|
67216
69949
|
& > .navi_list_scroll_container > .navi_list > .navi_list_virtual_filler {
|
|
67217
69950
|
width: var(--size-to-fill, 0px);
|
|
67218
69951
|
height: 100%;
|
|
69952
|
+
display: flex;
|
|
69953
|
+
|
|
69954
|
+
& > .navi_list_find_stand_in {
|
|
69955
|
+
width: calc(var(--x-find-line-count) * var(--x-find-line-size));
|
|
69956
|
+
writing-mode: vertical-lr;
|
|
69957
|
+
flex-shrink: 0;
|
|
69958
|
+
height: auto;
|
|
69959
|
+
}
|
|
67219
69960
|
}
|
|
67220
69961
|
}
|
|
67221
69962
|
|
|
@@ -69713,10 +72454,12 @@ const Fallback = ({
|
|
|
69713
72454
|
// settled after the first commit.
|
|
69714
72455
|
const VirtualFiller = ({
|
|
69715
72456
|
edge,
|
|
69716
|
-
itemCount
|
|
72457
|
+
itemCount,
|
|
72458
|
+
findChunks
|
|
69717
72459
|
}) => {
|
|
69718
72460
|
const listRows = useContext(ListRowsContext);
|
|
69719
|
-
const
|
|
72461
|
+
const virtualItemSize = listRows.virtualItemSizeSignal.value;
|
|
72462
|
+
const sizeToFill = itemCount * virtualItemSize;
|
|
69720
72463
|
if (!sizeToFill) {
|
|
69721
72464
|
return null;
|
|
69722
72465
|
}
|
|
@@ -69727,8 +72470,17 @@ const VirtualFiller = ({
|
|
|
69727
72470
|
"navi-virtual-filler": edge,
|
|
69728
72471
|
"aria-hidden": true,
|
|
69729
72472
|
style: {
|
|
69730
|
-
"--size-to-fill": `${sizeToFill}px
|
|
69731
|
-
|
|
72473
|
+
"--size-to-fill": `${sizeToFill}px`,
|
|
72474
|
+
"--x-find-line-size": findChunks ? `${virtualItemSize}px` : undefined
|
|
72475
|
+
},
|
|
72476
|
+
children: findChunks && findChunks.map(chunk => jsx("div", {
|
|
72477
|
+
className: "navi_list_find_stand_in",
|
|
72478
|
+
hidden: "until-found",
|
|
72479
|
+
style: {
|
|
72480
|
+
"--x-find-line-count": chunk.to - chunk.from
|
|
72481
|
+
},
|
|
72482
|
+
children: chunk.text
|
|
72483
|
+
}, `${chunk.from}_${chunk.to}`))
|
|
69732
72484
|
});
|
|
69733
72485
|
};
|
|
69734
72486
|
|
|
@@ -70361,6 +73113,7 @@ const VISIBILITY_HIDDEN_STYLE = {
|
|
|
70361
73113
|
*
|
|
70362
73114
|
* @type {import("ignore:preact").FunctionComponent<{
|
|
70363
73115
|
* renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
|
|
73116
|
+
* findText?: (item: any, index: number) => string,
|
|
70364
73117
|
* items?: any[],
|
|
70365
73118
|
* itemsAction?: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
|
|
70366
73119
|
* count?: number,
|
|
@@ -70379,6 +73132,13 @@ const VISIBILITY_HIDDEN_STYLE = {
|
|
|
70379
73132
|
* the run. `state.refreshing` says the rows drawn are the ones from before
|
|
70380
73133
|
* while the run reads the collection again — the list carries
|
|
70381
73134
|
* `navi-refreshing` for the same reason.
|
|
73135
|
+
* @param {(item: any, index: number) => string} [props.findText]
|
|
73136
|
+
* The text of a row as the browser's find in page (Cmd/Ctrl + F) sees it
|
|
73137
|
+
* while the row is not drawn — without it, find reaches only the rows in the
|
|
73138
|
+
* render window. The rows held off screen then carry that text where they
|
|
73139
|
+
* stand, hidden until the browser finds it there: it scrolls to the place,
|
|
73140
|
+
* and the list draws the row. One line per row. A row the run does not hold
|
|
73141
|
+
* yet has nothing to be found by.
|
|
70382
73142
|
* @param {any[]} [props.items]
|
|
70383
73143
|
* The collection, when it is held in memory: all of it, in order. Nothing is
|
|
70384
73144
|
* ever asked for — `itemsAction`, `count`, `pageSize` and `memoryBudget` have
|
|
@@ -70475,12 +73235,19 @@ const createStoreHoldingNothing = rowCount => {
|
|
|
70475
73235
|
};
|
|
70476
73236
|
};
|
|
70477
73237
|
|
|
73238
|
+
// How many rows share one element of the text a filler carries for find in
|
|
73239
|
+
// page (see findText). One element per row would put back the DOM nodes the
|
|
73240
|
+
// window saves; one per filler would be revealed whole — every row of it laid
|
|
73241
|
+
// out — by the first match found in it.
|
|
73242
|
+
const FIND_CHUNK_ROW_COUNT = 64;
|
|
73243
|
+
|
|
70478
73244
|
// What a run draws: the rows its window frames, a skeleton for each one the
|
|
70479
73245
|
// store does not hold, and fillers holding the room of the rows outside it.
|
|
70480
73246
|
// Which rows are held, and fetching the others, is the store's (see
|
|
70481
73247
|
// useItemStore).
|
|
70482
73248
|
const useRunRows = (store, {
|
|
70483
73249
|
renderItem,
|
|
73250
|
+
findText,
|
|
70484
73251
|
pageSize,
|
|
70485
73252
|
groupBy,
|
|
70486
73253
|
renderGroupLabel,
|
|
@@ -70688,6 +73455,37 @@ const useRunRows = (store, {
|
|
|
70688
73455
|
}
|
|
70689
73456
|
group.children.push(rowNode);
|
|
70690
73457
|
};
|
|
73458
|
+
// The text of the rows a filler stands for, cut in chunks aligned on the
|
|
73459
|
+
// run's own ranks: the window sliding changes the chunk at its edge and
|
|
73460
|
+
// leaves the others as they are.
|
|
73461
|
+
const getFindChunks = (from, to) => {
|
|
73462
|
+
if (!findText) {
|
|
73463
|
+
return null;
|
|
73464
|
+
}
|
|
73465
|
+
const chunks = [];
|
|
73466
|
+
let chunkFrom = from;
|
|
73467
|
+
while (chunkFrom < to) {
|
|
73468
|
+
const chunkIndex = Math.floor(rankOf(chunkFrom) / FIND_CHUNK_ROW_COUNT);
|
|
73469
|
+
const alignedTo = rowOf((chunkIndex + 1) * FIND_CHUNK_ROW_COUNT);
|
|
73470
|
+
const chunkTo = alignedTo < to ? alignedTo : to;
|
|
73471
|
+
const lines = [];
|
|
73472
|
+
let lineRowIndex = chunkFrom;
|
|
73473
|
+
while (lineRowIndex < chunkTo) {
|
|
73474
|
+
const item = getItemAt(lineRowIndex);
|
|
73475
|
+
// A row not held has nothing to find, but keeps its line: the rows
|
|
73476
|
+
// after it stay at their place.
|
|
73477
|
+
lines.push(item === undefined ? "" : toFindLine(findText(item, lineRowIndex)));
|
|
73478
|
+
lineRowIndex++;
|
|
73479
|
+
}
|
|
73480
|
+
chunks.push({
|
|
73481
|
+
from: chunkFrom,
|
|
73482
|
+
to: chunkTo,
|
|
73483
|
+
text: lines.join("\n")
|
|
73484
|
+
});
|
|
73485
|
+
chunkFrom = chunkTo;
|
|
73486
|
+
}
|
|
73487
|
+
return chunks;
|
|
73488
|
+
};
|
|
70691
73489
|
// The room held for this run's own rows that the window leaves out. It
|
|
70692
73490
|
// belongs to the run and not to the list: a list is not necessarily made of
|
|
70693
73491
|
// one run, and what sits before or after it (a header, rows given one by
|
|
@@ -70695,7 +73493,8 @@ const useRunRows = (store, {
|
|
|
70695
73493
|
if (windowFrom > runStart) {
|
|
70696
73494
|
rows.push(jsx(VirtualFiller, {
|
|
70697
73495
|
edge: "before",
|
|
70698
|
-
itemCount: windowFrom - runStart
|
|
73496
|
+
itemCount: windowFrom - runStart,
|
|
73497
|
+
findChunks: getFindChunks(runStart, windowFrom)
|
|
70699
73498
|
}, "navi-list-filler-before"));
|
|
70700
73499
|
}
|
|
70701
73500
|
const renderItemState = {
|
|
@@ -70797,11 +73596,20 @@ const useRunRows = (store, {
|
|
|
70797
73596
|
if (runEnd > windowTo) {
|
|
70798
73597
|
rows.push(jsx(VirtualFiller, {
|
|
70799
73598
|
edge: "after",
|
|
70800
|
-
itemCount: runEnd - windowTo
|
|
73599
|
+
itemCount: runEnd - windowTo,
|
|
73600
|
+
findChunks: getFindChunks(windowTo, runEnd)
|
|
70801
73601
|
}, "navi-list-filler-after"));
|
|
70802
73602
|
}
|
|
70803
73603
|
return rows;
|
|
70804
73604
|
};
|
|
73605
|
+
// One row, one line: a line break inside it would push every row below off
|
|
73606
|
+
// its place.
|
|
73607
|
+
const toFindLine = text => {
|
|
73608
|
+
if (text === undefined || text === null) {
|
|
73609
|
+
return "";
|
|
73610
|
+
}
|
|
73611
|
+
return String(text).replace(/[\r\n]+/g, " ");
|
|
73612
|
+
};
|
|
70805
73613
|
|
|
70806
73614
|
// A run's row that has not arrived, standing where the real one will. It never
|
|
70807
73615
|
// reaches ListItemUI (see ListItemSkeletonResolver), so it is drawn among the
|
|
@@ -78181,6 +80989,7 @@ const useWheelInteractions = ({
|
|
|
78181
80989
|
attemptInteraction,
|
|
78182
80990
|
cancelAnim,
|
|
78183
80991
|
setPos,
|
|
80992
|
+
releaseScrub,
|
|
78184
80993
|
snapPosToRow,
|
|
78185
80994
|
getItemSize,
|
|
78186
80995
|
viewportMain,
|
|
@@ -78588,6 +81397,8 @@ const useWheelInteractions = ({
|
|
|
78588
81397
|
clearTimeout(settleTimer);
|
|
78589
81398
|
stopClaimingGesture();
|
|
78590
81399
|
cancelAnim();
|
|
81400
|
+
// Nothing is left to settle the scrub in progress.
|
|
81401
|
+
releaseScrub();
|
|
78591
81402
|
document.removeEventListener("pointerdown", onDocumentPointerDown, {
|
|
78592
81403
|
capture: true
|
|
78593
81404
|
});
|
|
@@ -78753,11 +81564,11 @@ function WheelUI(props) {
|
|
|
78753
81564
|
...props,
|
|
78754
81565
|
ref: inputRef,
|
|
78755
81566
|
type: "navi_js",
|
|
78756
|
-
//
|
|
78757
|
-
// the
|
|
78758
|
-
// stops setUIState from auto-firing the action on every value change
|
|
78759
|
-
//
|
|
78760
|
-
//
|
|
81567
|
+
// An arrow or a tap changes the value at once while the row glides into
|
|
81568
|
+
// place, and the action must wait for the wheel to come to rest. "custom"
|
|
81569
|
+
// stops setUIState from auto-firing the action on every value change;
|
|
81570
|
+
// commitSelection dispatches it explicitly on settle. A caller can still
|
|
81571
|
+
// override actionEvent.
|
|
78761
81572
|
actionEvent: props.actionEvent ?? "custom"
|
|
78762
81573
|
}, {
|
|
78763
81574
|
controlType: "input"
|
|
@@ -78853,6 +81664,11 @@ function WheelUI(props) {
|
|
|
78853
81664
|
// while our own scroll-driven selection does not scroll a second time. Holds the
|
|
78854
81665
|
// TARGET so rapid inputs step from it and accumulate. null = nothing pending.
|
|
78855
81666
|
const centeredIndexRef = useRef(null);
|
|
81667
|
+
// A user scrub moved the wheel onto a row that is not the value yet: set at
|
|
81668
|
+
// the first row it crosses, cleared when it settles (commitSelection), when
|
|
81669
|
+
// an arrow or a tap sets a value at once (glideToIndex), or when it is
|
|
81670
|
+
// dropped (releaseScrub). See setPos.
|
|
81671
|
+
const scrubHoldsRowRef = useRef(false);
|
|
78856
81672
|
// The wheel does NOT use native scroll. `posRef` is our own virtual scroll
|
|
78857
81673
|
// position (px, main axis); the list track is translated by -pos. This is the
|
|
78858
81674
|
// single source of truth, wrapped modulo the real-list extent in loop mode, so
|
|
@@ -78902,8 +81718,22 @@ function WheelUI(props) {
|
|
|
78902
81718
|
...style
|
|
78903
81719
|
};
|
|
78904
81720
|
|
|
78905
|
-
// Main-axis size of the viewport, from the orientation.
|
|
78906
|
-
|
|
81721
|
+
// Main-axis size of the viewport, from the orientation. Cached like the row
|
|
81722
|
+
// size: applyOffset runs right after each window commit, where reading the
|
|
81723
|
+
// box would force a layout at every row crossing. The observer below keeps it
|
|
81724
|
+
// current; 0 (no box yet) is never kept, so the next read measures again.
|
|
81725
|
+
const viewportSizeRef = useRef(0);
|
|
81726
|
+
const measureViewportMain = vp => isHorizontal ? vp.clientWidth : vp.clientHeight;
|
|
81727
|
+
const viewportMain = vp => {
|
|
81728
|
+
if (viewportSizeRef.current > 0) {
|
|
81729
|
+
return viewportSizeRef.current;
|
|
81730
|
+
}
|
|
81731
|
+
const size = measureViewportMain(vp);
|
|
81732
|
+
if (size > 0) {
|
|
81733
|
+
viewportSizeRef.current = size;
|
|
81734
|
+
}
|
|
81735
|
+
return size;
|
|
81736
|
+
};
|
|
78907
81737
|
|
|
78908
81738
|
// Row size (px). Rows are uniform, so one measurement (cached) drives all the
|
|
78909
81739
|
// geometry — positions are then computed from value indices, not read off the
|
|
@@ -78974,6 +81804,21 @@ function WheelUI(props) {
|
|
|
78974
81804
|
applyOffset(vp);
|
|
78975
81805
|
}
|
|
78976
81806
|
};
|
|
81807
|
+
// Refreshes the cached viewport size (see viewportMain). Read in the observer
|
|
81808
|
+
// callback, where the layout is already clean. A new size moves the center
|
|
81809
|
+
// window, so the offset is re-applied with it.
|
|
81810
|
+
useLayoutEffect(() => {
|
|
81811
|
+
const vp = getViewport();
|
|
81812
|
+
viewportSizeRef.current = 0;
|
|
81813
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
81814
|
+
viewportSizeRef.current = measureViewportMain(vp);
|
|
81815
|
+
applyOffset(vp);
|
|
81816
|
+
});
|
|
81817
|
+
resizeObserver.observe(vp);
|
|
81818
|
+
return () => {
|
|
81819
|
+
resizeObserver.disconnect();
|
|
81820
|
+
};
|
|
81821
|
+
}, [isHorizontal]);
|
|
78977
81822
|
|
|
78978
81823
|
// The value index at the center for the current position.
|
|
78979
81824
|
const centeredIndex = vp => {
|
|
@@ -79012,12 +81857,14 @@ function WheelUI(props) {
|
|
|
79012
81857
|
posRef.current = clampNumber(posRef.current, 0, (count - 1) * size);
|
|
79013
81858
|
};
|
|
79014
81859
|
|
|
79015
|
-
// `live`
|
|
79016
|
-
//
|
|
79017
|
-
//
|
|
79018
|
-
//
|
|
79019
|
-
//
|
|
79020
|
-
//
|
|
81860
|
+
// `live` marks a user scrub (drag, momentum, wheel scroll). A scrub moves the
|
|
81861
|
+
// drawing, not the value: the row it reaches is held by the wheel, and only
|
|
81862
|
+
// commitSelection turns it into the value, once the motion settles. Reported
|
|
81863
|
+
// at each crossing, every notch would be a value change for all that is
|
|
81864
|
+
// bound to the wheel — its group, its form, a signal, the address, which
|
|
81865
|
+
// records where a value was put down, not the path the finger took
|
|
81866
|
+
// (navigation.md, "A value written per frame"). A programmatic move
|
|
81867
|
+
// (centerOnIndex) omits `live`: it places the value the wheel already has.
|
|
79021
81868
|
const setPos = (vp, pos, {
|
|
79022
81869
|
live = false
|
|
79023
81870
|
} = {}) => {
|
|
@@ -79028,18 +81875,25 @@ function WheelUI(props) {
|
|
|
79028
81875
|
renderPos(vp);
|
|
79029
81876
|
if (live && interactive) {
|
|
79030
81877
|
const index = centeredIndex(vp);
|
|
79031
|
-
if (index !== centeredIndexRef.current) {
|
|
79032
|
-
|
|
79033
|
-
|
|
79034
|
-
|
|
79035
|
-
|
|
79036
|
-
// fight the in-progress motion.
|
|
79037
|
-
centeredIndexRef.current = index;
|
|
79038
|
-
requestSelectValue(item.value, new CustomEvent("navi_wheel_scrub"));
|
|
79039
|
-
}
|
|
81878
|
+
if (index !== centeredIndexRef.current && trackedItemsRef.current[index]) {
|
|
81879
|
+
// In step with the scrub so the next arrow or tap steps from the row
|
|
81880
|
+
// the wheel is on (see currentTargetIndex).
|
|
81881
|
+
centeredIndexRef.current = index;
|
|
81882
|
+
scrubHoldsRowRef.current = true;
|
|
79040
81883
|
}
|
|
79041
81884
|
}
|
|
79042
81885
|
};
|
|
81886
|
+
// The scrub was dropped before it settled (the wheel stopped being
|
|
81887
|
+
// interactive, or its input bindings were torn down): the row it held never
|
|
81888
|
+
// became the value. Forgetting it lets the next sync put the wheel back on
|
|
81889
|
+
// the value.
|
|
81890
|
+
const releaseScrub = () => {
|
|
81891
|
+
if (!scrubHoldsRowRef.current) {
|
|
81892
|
+
return;
|
|
81893
|
+
}
|
|
81894
|
+
scrubHoldsRowRef.current = false;
|
|
81895
|
+
centeredIndexRef.current = null;
|
|
81896
|
+
};
|
|
79043
81897
|
|
|
79044
81898
|
// Returns whether it actually interrupted a motion in flight — lets the caller
|
|
79045
81899
|
// tell a fresh gesture from one that "caught" a spinning/gliding wheel.
|
|
@@ -79075,6 +81929,7 @@ function WheelUI(props) {
|
|
|
79075
81929
|
renderPos(vp);
|
|
79076
81930
|
}
|
|
79077
81931
|
centeredIndexRef.current = index;
|
|
81932
|
+
scrubHoldsRowRef.current = false;
|
|
79078
81933
|
if (!interactive || glideFromOutsideRef.current) {
|
|
79079
81934
|
return;
|
|
79080
81935
|
}
|
|
@@ -79272,6 +82127,7 @@ function WheelUI(props) {
|
|
|
79272
82127
|
// inputs accumulate.
|
|
79273
82128
|
const glideToIndex = (vp, index, event) => {
|
|
79274
82129
|
centeredIndexRef.current = index;
|
|
82130
|
+
scrubHoldsRowRef.current = false;
|
|
79275
82131
|
requestSelectValue(trackedItemsRef.current[index].value, event);
|
|
79276
82132
|
glideTo(vp, glideTargetFor(vp, index));
|
|
79277
82133
|
};
|
|
@@ -79302,12 +82158,12 @@ function WheelUI(props) {
|
|
|
79302
82158
|
// Sync the center with the current value — used on first display and whenever
|
|
79303
82159
|
// the controlled value changes from outside.
|
|
79304
82160
|
const syncCenterToSelection = (viewportEl, behavior) => {
|
|
79305
|
-
// A fling in flight
|
|
79306
|
-
//
|
|
79307
|
-
// arriving meanwhile
|
|
79308
|
-
//
|
|
79309
|
-
//
|
|
79310
|
-
if (momentumRef.current !== null) {
|
|
82161
|
+
// A fling in flight, or a scrub holding a row, commits on settle: the finger
|
|
82162
|
+
// owns the wheel until then, and the row it leaves the wheel on overrules a
|
|
82163
|
+
// value arriving meanwhile. A glide is different: its target is re-aimed
|
|
82164
|
+
// below (glideTo never restarts the loop), so a value set from outside
|
|
82165
|
+
// mid-glide is where the wheel ends up.
|
|
82166
|
+
if (momentumRef.current !== null || scrubHoldsRowRef.current) {
|
|
79311
82167
|
return;
|
|
79312
82168
|
}
|
|
79313
82169
|
if (trackedItemsRef.current.length === 0) {
|
|
@@ -79390,6 +82246,7 @@ function WheelUI(props) {
|
|
|
79390
82246
|
attemptInteraction,
|
|
79391
82247
|
cancelAnim,
|
|
79392
82248
|
setPos,
|
|
82249
|
+
releaseScrub,
|
|
79393
82250
|
snapPosToRow,
|
|
79394
82251
|
getItemSize,
|
|
79395
82252
|
viewportMain,
|
|
@@ -86628,6 +89485,105 @@ const createSlot = (SlotRenderer = Box) => {
|
|
|
86628
89485
|
return [Slot, SlotFill];
|
|
86629
89486
|
};
|
|
86630
89487
|
|
|
89488
|
+
/**
|
|
89489
|
+
* The on-screen keyboard overlaying the app rather than resizing the viewport,
|
|
89490
|
+
* for an app that asks for it (the VirtualKeyboard API — Chromium only). See
|
|
89491
|
+
* virtual_keyboard.js in @jsenv/dom for what that trades away and what it
|
|
89492
|
+
* gives back.
|
|
89493
|
+
*
|
|
89494
|
+
* Offered, never taken by default. Overlaying means redoing by hand what the
|
|
89495
|
+
* browser does for a viewport that shrinks, and on a phone every piece of it
|
|
89496
|
+
* hid a Chrome behavior nothing reports: the focused field left under the
|
|
89497
|
+
* keyboard, a suggestion strip painted above the keyboard outside its
|
|
89498
|
+
* geometry, geometrychange firing while the page is merely scrolled. Firefox
|
|
89499
|
+
* and Safari shrink the viewport whatever the app wants, and navi follows that
|
|
89500
|
+
* already (--navi-vvh), so the default is the path every browser shares.
|
|
89501
|
+
*
|
|
89502
|
+
* What the overlay buys: no reflow of the page underneath — a resizing
|
|
89503
|
+
* viewport is a resize of everything, fired transiently every time focus goes
|
|
89504
|
+
* from one input to the next — while navi still sizes what escapes normal flow
|
|
89505
|
+
* against a rectangle the keyboard is subtracted from
|
|
89506
|
+
* (--navi-keyboard-inset-bottom, safe_area.js).
|
|
89507
|
+
*
|
|
89508
|
+
* Taking the deal means taking over what the browser stops doing under it:
|
|
89509
|
+
* bringing the focused field out from under the keyboard. A viewport that
|
|
89510
|
+
* shrinks makes the browser scroll to the field; a keyboard that merely paints
|
|
89511
|
+
* over the page leaves whatever is under it under it. So enabling the overlay
|
|
89512
|
+
* also installs that scroll, with two things safe_area.js provides: a
|
|
89513
|
+
* scroll-padding-bottom that counts the keyboard in, plus an allowance for the
|
|
89514
|
+
* strip Chrome paints above it without reporting it
|
|
89515
|
+
* (--navi-keyboard-strip-allowance), which bounds the band the field is brought
|
|
89516
|
+
* into; and room at the end of the scroller, so a field near the end of the
|
|
89517
|
+
* page has somewhere to go. All of it reads 0 while the keyboard does not
|
|
89518
|
+
* overlay.
|
|
89519
|
+
*
|
|
89520
|
+
* Only the arrival is navi's: once the field is in view, Chrome keeps the caret
|
|
89521
|
+
* there itself as typing grows a textarea, against that same
|
|
89522
|
+
* scroll-padding-bottom.
|
|
89523
|
+
*/
|
|
89524
|
+
|
|
89525
|
+
|
|
89526
|
+
/**
|
|
89527
|
+
* Makes the on-screen keyboard overlay the app instead of shrinking the
|
|
89528
|
+
* viewport, and brings the focused field out from under it. A no-op on
|
|
89529
|
+
* Firefox/Safari, which have no VirtualKeyboard API.
|
|
89530
|
+
*
|
|
89531
|
+
* @returns {() => void} Goes back to the viewport shrinking.
|
|
89532
|
+
*/
|
|
89533
|
+
const enableVirtualKeyboardOverlay = () => {
|
|
89534
|
+
if (!setVirtualKeyboardOverlaysContent(true)) {
|
|
89535
|
+
return () => {};
|
|
89536
|
+
}
|
|
89537
|
+
// The keyboard rising, or changing height (suggestion strip, emoji panel).
|
|
89538
|
+
const unsubscribeGeometryChange =
|
|
89539
|
+
subscribeVirtualKeyboardGeometryChange(revealFocusedField);
|
|
89540
|
+
// From one field to the next with the keyboard up: when both want the same
|
|
89541
|
+
// keyboard it does not move, and no geometrychange fires.
|
|
89542
|
+
document.addEventListener("focusin", revealFocusedField, { capture: true });
|
|
89543
|
+
return () => {
|
|
89544
|
+
unsubscribeGeometryChange();
|
|
89545
|
+
document.removeEventListener("focusin", revealFocusedField, {
|
|
89546
|
+
capture: true,
|
|
89547
|
+
});
|
|
89548
|
+
setVirtualKeyboardOverlaysContent(false);
|
|
89549
|
+
};
|
|
89550
|
+
};
|
|
89551
|
+
|
|
89552
|
+
const revealFocusedField = () => {
|
|
89553
|
+
if (getVirtualKeyboardOverlayHeight() === 0) {
|
|
89554
|
+
return;
|
|
89555
|
+
}
|
|
89556
|
+
const field = document.activeElement;
|
|
89557
|
+
if (!isEditableTarget(field)) {
|
|
89558
|
+
return;
|
|
89559
|
+
}
|
|
89560
|
+
if (isInDocumentBand(field)) {
|
|
89561
|
+
return;
|
|
89562
|
+
}
|
|
89563
|
+
// "center" rather than "nearest": nearest stops the field on the band's
|
|
89564
|
+
// edge, clear of the strip only by the allowance. The middle of the band
|
|
89565
|
+
// clears a strip taller than guessed too — and centering what is not fully
|
|
89566
|
+
// visible is what Chrome does itself when the viewport shrinks.
|
|
89567
|
+
scrollIntoViewThroughScrollables(field, {
|
|
89568
|
+
block: "center",
|
|
89569
|
+
behavior: "instant",
|
|
89570
|
+
});
|
|
89571
|
+
};
|
|
89572
|
+
// The band the document leaves visible: inside its scroll-padding (the bars,
|
|
89573
|
+
// the keyboard and its strip, see safe_area.js). Against the window rather
|
|
89574
|
+
// than each scroller's own band: the keyboard covers the window, and a field
|
|
89575
|
+
// hidden inside its own scroller is what the browser's focus already scrolls
|
|
89576
|
+
// to.
|
|
89577
|
+
const isInDocumentBand = (field) => {
|
|
89578
|
+
const documentElement = document.documentElement;
|
|
89579
|
+
const style = getComputedStyle(documentElement);
|
|
89580
|
+
const bandTop = parseFloat(style.scrollPaddingTop) || 0;
|
|
89581
|
+
const bandBottom =
|
|
89582
|
+
documentElement.clientHeight - (parseFloat(style.scrollPaddingBottom) || 0);
|
|
89583
|
+
const { top, bottom } = field.getBoundingClientRect();
|
|
89584
|
+
return top >= bandTop && bottom <= bandBottom;
|
|
89585
|
+
};
|
|
89586
|
+
|
|
86631
89587
|
/*
|
|
86632
89588
|
* - Usage
|
|
86633
89589
|
* useEffect(() => {
|
|
@@ -86791,5 +89747,5 @@ const UserSvg = () => jsx("svg", {
|
|
|
86791
89747
|
})
|
|
86792
89748
|
});
|
|
86793
89749
|
|
|
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 };
|
|
89750
|
+
export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, ControlSwap, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NetworkPolicyError, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRange, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, constraintFromValidityRule, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, dispatchRequestSetUIState, enableDebugActions, enableDebugOnDocumentLoading, enableVirtualKeyboardOverlay, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, getNowHours, getNowHoursRoundedToStep, isCellSelected, isColumnSelected, isNetworkPolicyError, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markAsOutsideTextFlow, markErrorAsDisplayedBy, moveArrayItemByIndex, moveFocusTo, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, preloadUrl, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, routeFallback, scrollActivitySignal, setBaseUrl, setNetworkPolicy, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useNetworkPolicyReason, useOrderedColumns, usePickerState, usePopupMode, useRouteStatus, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideContainer, useSlideValue, useStateArray, useTitleLevel, useTransitionCover, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
|
|
86795
89751
|
//# sourceMappingURL=jsenv_navi.js.map
|