@jsenv/navi 0.29.90 → 0.29.92
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/jsenv_navi.js +1280 -163
- package/dist/jsenv_navi.js.map +116 -29
- package/docs/AI_INSTRUCTIONS.md +14 -1
- package/docs/actions.md +14 -4
- package/docs/control_object.md +12 -0
- package/docs/control_value.md +73 -0
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
|
|
6
6
|
export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
|
|
7
|
-
import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, clickIsSuppressed, isTouchDrivenEvent, scrollIntoViewScoped, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement
|
|
7
|
+
import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, clickIsSuppressed, isTouchDrivenEvent, scrollIntoViewScoped, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, stringifyStyle as stringifyStyle$1, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
|
|
8
8
|
export { clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
|
|
9
9
|
import { signal, computed, effect, batch, untracked, useSignal } from "@preact/signals";
|
|
10
10
|
import { createContext, isValidElement, h, Fragment, render, toChildArray, options, cloneElement } from "preact";
|
|
@@ -38,7 +38,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
38
38
|
* any of these, and a number is the last resort, not the first tool.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
const css$
|
|
41
|
+
const css$13 = /* css */`
|
|
42
42
|
@layer navi {
|
|
43
43
|
:root {
|
|
44
44
|
/* A control that overlaps its neighbours (the members of a Group share
|
|
@@ -92,7 +92,7 @@ const css$12 = /* css */`
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
`;
|
|
95
|
-
import.meta.css = [css$
|
|
95
|
+
import.meta.css = [css$13, "@jsenv/navi/src/navi_z_indexes.js"];
|
|
96
96
|
|
|
97
97
|
const addIntoArray = (array, ...valuesToAdd) => {
|
|
98
98
|
if (valuesToAdd.length === 1) {
|
|
@@ -342,11 +342,11 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
342
342
|
*
|
|
343
343
|
* Two decisions worth knowing before reading:
|
|
344
344
|
*
|
|
345
|
-
* - **navi places the target itself, every time.**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
345
|
+
* - **navi places the target itself, every time.** The scroll the browser
|
|
346
|
+
* would have done — target against the top edge, instantly — is applied
|
|
347
|
+
* here, after layout, so one rule holds whether the target was there all
|
|
348
|
+
* along or arrived late; `setUrlTargetOptions` lets an app pick another
|
|
349
|
+
* alignment or a smooth behavior. A page with nothing to scroll simply
|
|
350
350
|
* does not move, which is the whole of the "the list already fits on screen"
|
|
351
351
|
* case — the transient mark alone then says which one was meant.
|
|
352
352
|
*
|
|
@@ -361,7 +361,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
361
361
|
* the very first render and the browser does everything on its own.
|
|
362
362
|
*/
|
|
363
363
|
const URL_TARGET_ATTRIBUTE = "data-url-target";
|
|
364
|
-
const css$
|
|
364
|
+
const css$12 = /* css */`
|
|
365
365
|
@layer navi {
|
|
366
366
|
[${URL_TARGET_ATTRIBUTE}] {
|
|
367
367
|
animation: navi_url_target var(--navi-url-target-duration, 2000ms)
|
|
@@ -379,10 +379,10 @@ const css$11 = /* css */`
|
|
|
379
379
|
}
|
|
380
380
|
}
|
|
381
381
|
`;
|
|
382
|
-
import.meta.css = [css$
|
|
382
|
+
import.meta.css = [css$12, "@jsenv/navi/src/nav/url_target/url_target.js"];
|
|
383
383
|
let urlTargetOptions = {
|
|
384
|
-
block: "
|
|
385
|
-
behavior: "
|
|
384
|
+
block: "start",
|
|
385
|
+
behavior: "instant",
|
|
386
386
|
markDuration: 2000,
|
|
387
387
|
graceAfterIdle: 1000,
|
|
388
388
|
maxWait: 10_000
|
|
@@ -392,12 +392,12 @@ let urlTargetOptions = {
|
|
|
392
392
|
* Adjusts how navi answers the element designated by the URL hash.
|
|
393
393
|
*
|
|
394
394
|
* @param {object} options
|
|
395
|
-
* @param {"start"|"center"|"end"|"nearest"} [options.block="
|
|
396
|
-
* Vertical alignment of the scroll. "
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
395
|
+
* @param {"start"|"center"|"end"|"nearest"} [options.block="start"]
|
|
396
|
+
* Vertical alignment of the scroll. "start" by default — the alignment the
|
|
397
|
+
* browser itself uses when it answers a fragment.
|
|
398
|
+
* @param {ScrollBehavior} [options.behavior="instant"]
|
|
399
|
+
* "instant" by default, like the browser. When set to "smooth", it is
|
|
400
|
+
* overridden with "instant" under `prefers-reduced-motion: reduce`.
|
|
401
401
|
* @param {number} [options.markDuration=2000]
|
|
402
402
|
* How long, in ms, the element carries `data-url-target`. Published to CSS as
|
|
403
403
|
* `--navi-url-target-duration`.
|
|
@@ -6403,7 +6403,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
6403
6403
|
* - Arrow automatically shows when pointing at a valid anchor element
|
|
6404
6404
|
* - Centers in viewport when no anchor element provided or anchor is too big
|
|
6405
6405
|
*/
|
|
6406
|
-
const css$
|
|
6406
|
+
const css$11 = /* css */`
|
|
6407
6407
|
@layer navi {
|
|
6408
6408
|
.navi_callout {
|
|
6409
6409
|
/* A callout is parented to what it explains, so it inherits from it — and
|
|
@@ -6646,7 +6646,7 @@ const openCallout = (message, {
|
|
|
6646
6646
|
skipFocus = false,
|
|
6647
6647
|
debug = () => {}
|
|
6648
6648
|
} = {}) => {
|
|
6649
|
-
import.meta.css = [css$
|
|
6649
|
+
import.meta.css = [css$11, "@jsenv/navi/src/control/rules/callout/callout.js"];
|
|
6650
6650
|
if (debug === true) {
|
|
6651
6651
|
debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
|
|
6652
6652
|
}
|
|
@@ -8009,7 +8009,14 @@ const isControlBusy = (field) => {
|
|
|
8009
8009
|
return true;
|
|
8010
8010
|
}
|
|
8011
8011
|
const { boundAction } = field;
|
|
8012
|
-
|
|
8012
|
+
// An optimistic control stays interactive while its bound action runs:
|
|
8013
|
+
// a new interaction is queued behind the run (see the action queue in
|
|
8014
|
+
// control_hooks.jsx) rather than refused.
|
|
8015
|
+
if (
|
|
8016
|
+
!field.optimistic &&
|
|
8017
|
+
boundAction &&
|
|
8018
|
+
boundAction.runningStateSignal.value === RUNNING
|
|
8019
|
+
) {
|
|
8013
8020
|
return true;
|
|
8014
8021
|
}
|
|
8015
8022
|
if (field.loadingFromParent) {
|
|
@@ -18866,7 +18873,7 @@ const setupNetworkMonitoring = () => {
|
|
|
18866
18873
|
};
|
|
18867
18874
|
setupNetworkMonitoring();
|
|
18868
18875
|
|
|
18869
|
-
installImportMetaCssBuild(import.meta);const css
|
|
18876
|
+
installImportMetaCssBuild(import.meta);const css$10 = /* css */`
|
|
18870
18877
|
.navi_loading_indicator_fluid_container {
|
|
18871
18878
|
position: relative;
|
|
18872
18879
|
display: flex;
|
|
@@ -18898,7 +18905,7 @@ const LoadingIndicatorFluid = ({
|
|
|
18898
18905
|
visuallyHidden,
|
|
18899
18906
|
...rest
|
|
18900
18907
|
}) => {
|
|
18901
|
-
import.meta.css = [css
|
|
18908
|
+
import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
|
|
18902
18909
|
const ref = useRef(null);
|
|
18903
18910
|
// The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
|
|
18904
18911
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
@@ -19103,7 +19110,7 @@ const LoadingRectangleSvg = ({
|
|
|
19103
19110
|
});
|
|
19104
19111
|
};
|
|
19105
19112
|
|
|
19106
|
-
installImportMetaCssBuild(import.meta);const css
|
|
19113
|
+
installImportMetaCssBuild(import.meta);const css$$ = /* css */`
|
|
19107
19114
|
.navi_loading_outline_wrapper {
|
|
19108
19115
|
position: absolute;
|
|
19109
19116
|
/* Controls place the outline slightly outside their box, right on top of
|
|
@@ -19140,7 +19147,7 @@ installImportMetaCssBuild(import.meta);const css$_ = /* css */`
|
|
|
19140
19147
|
}
|
|
19141
19148
|
`;
|
|
19142
19149
|
const LoadingOutline = props => {
|
|
19143
|
-
import.meta.css = [css
|
|
19150
|
+
import.meta.css = [css$$, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
|
|
19144
19151
|
if (props.containerRef) {
|
|
19145
19152
|
const container = props.containerRef.current;
|
|
19146
19153
|
if (!container) {
|
|
@@ -19430,7 +19437,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
|
|
|
19430
19437
|
};
|
|
19431
19438
|
|
|
19432
19439
|
installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
|
|
19433
|
-
const css$
|
|
19440
|
+
const css$_ = /* css */`
|
|
19434
19441
|
@layer navi {
|
|
19435
19442
|
.navi_text {
|
|
19436
19443
|
&[data-skeleton] {
|
|
@@ -19951,7 +19958,7 @@ const TextShrinkWrap = props => {
|
|
|
19951
19958
|
});
|
|
19952
19959
|
};
|
|
19953
19960
|
const TextUI = props => {
|
|
19954
|
-
import.meta.css = [css$
|
|
19961
|
+
import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
|
|
19955
19962
|
let {
|
|
19956
19963
|
ref,
|
|
19957
19964
|
spacing,
|
|
@@ -23198,6 +23205,7 @@ const CONTROL_PROP_SET = new Set([
|
|
|
23198
23205
|
"resetOnCancel",
|
|
23199
23206
|
"resetOnAbort",
|
|
23200
23207
|
"resetOnError",
|
|
23208
|
+
"optimistic",
|
|
23201
23209
|
|
|
23202
23210
|
"charGuard",
|
|
23203
23211
|
"maxLengthGuard",
|
|
@@ -23897,7 +23905,15 @@ const useUIStateController = (
|
|
|
23897
23905
|
// Uses the in-memory registry instead of DOM queries so this works even
|
|
23898
23906
|
// when sibling items are virtualized (not in the DOM).
|
|
23899
23907
|
// Form scoping is preserved by comparing parentUIStateController references.
|
|
23900
|
-
|
|
23908
|
+
// Checked, not truthy: a radio holding `false` (two rows asking a
|
|
23909
|
+
// yes/no) is as checked as one holding a name, and the row that was
|
|
23910
|
+
// checked before has to let go all the same.
|
|
23911
|
+
if (
|
|
23912
|
+
isRadio &&
|
|
23913
|
+
newUIState !== undefined &&
|
|
23914
|
+
controller.name &&
|
|
23915
|
+
!controlProxyFor
|
|
23916
|
+
) {
|
|
23901
23917
|
const siblings = getRadioSiblings(controller);
|
|
23902
23918
|
if (siblings) {
|
|
23903
23919
|
const siblingUncheckEvent = new CustomEvent(
|
|
@@ -24422,6 +24438,18 @@ const GROUP_DEFAULTS = {
|
|
|
24422
24438
|
if (isNamelessGrouping(child, child.uiState)) {
|
|
24423
24439
|
return newUIState;
|
|
24424
24440
|
}
|
|
24441
|
+
if (
|
|
24442
|
+
newUIState !== null &&
|
|
24443
|
+
typeof newUIState === "object" &&
|
|
24444
|
+
Object.keys(newUIState).length === 0
|
|
24445
|
+
) {
|
|
24446
|
+
// An object with nothing in it is not a partial answer, it is the
|
|
24447
|
+
// absence of one — a group being cleared — and every child empties. A
|
|
24448
|
+
// partial object leaves the children it does not name alone, which is
|
|
24449
|
+
// what CANNOT_DERIVE says below: a value that mentions `start` says
|
|
24450
|
+
// nothing about `end`, an empty one says there is no answer at all.
|
|
24451
|
+
return undefined;
|
|
24452
|
+
}
|
|
24425
24453
|
return CANNOT_DERIVE;
|
|
24426
24454
|
},
|
|
24427
24455
|
},
|
|
@@ -24465,6 +24493,7 @@ const useUIGroupStateController = (
|
|
|
24465
24493
|
childControlFilter,
|
|
24466
24494
|
aggregateChildStates,
|
|
24467
24495
|
distributeChildUIState,
|
|
24496
|
+
distributeChildStates,
|
|
24468
24497
|
wantRequesterButtonState,
|
|
24469
24498
|
uiActionInternal,
|
|
24470
24499
|
allowCapture = false,
|
|
@@ -24481,7 +24510,7 @@ const useUIGroupStateController = (
|
|
|
24481
24510
|
// whatever IT says — a "HH:MM", an ISO duration — and the shape checks in
|
|
24482
24511
|
// setUIState below are about the default shape, not about that one.
|
|
24483
24512
|
const stateShapeIsTheDefaultOne =
|
|
24484
|
-
!aggregateChildStates && !distributeChildUIState;
|
|
24513
|
+
!aggregateChildStates && !distributeChildUIState && !distributeChildStates;
|
|
24485
24514
|
const defaults = GROUP_DEFAULTS[controlType] ?? GROUP_DEFAULTS[stateType];
|
|
24486
24515
|
const resolvedChildControlFilter =
|
|
24487
24516
|
childControlFilter ?? defaults?.childControlFilter ?? null;
|
|
@@ -24489,6 +24518,12 @@ const useUIGroupStateController = (
|
|
|
24489
24518
|
aggregateChildStates ?? defaults?.aggregateChildStates;
|
|
24490
24519
|
const resolvedDistributeChildUIState =
|
|
24491
24520
|
distributeChildUIState ?? defaults?.distributeChildUIState;
|
|
24521
|
+
// The plural half of the pair: `aggregateChildStates` already sees all the
|
|
24522
|
+
// children at once, and a group whose value is not one key per child usually
|
|
24523
|
+
// needs the same view on the way down — which of four seats each player takes
|
|
24524
|
+
// cannot be decided one seat at a time. Given both, the plural one wins.
|
|
24525
|
+
const resolvedDistributeChildStates =
|
|
24526
|
+
distributeChildStates ?? defaults?.distributeChildStates;
|
|
24492
24527
|
if (
|
|
24493
24528
|
typeof resolvedAggregateChildStates !== "function" ||
|
|
24494
24529
|
typeof resolvedDistributeChildUIState !== "function"
|
|
@@ -24743,6 +24778,48 @@ const useUIGroupStateController = (
|
|
|
24743
24778
|
// Where the group puts a value on ONE child: the only place that knows
|
|
24744
24779
|
// what each child gets, and the only one that sees a child it cannot
|
|
24745
24780
|
// place — see warnChildAnswersForItself.
|
|
24781
|
+
// One pass over every child, which is what a plural distribute needs:
|
|
24782
|
+
// it is asked once, sees the whole group, and answers for all of them.
|
|
24783
|
+
placeChildrenUIState: (groupUIState, e) => {
|
|
24784
|
+
if (!resolvedDistributeChildStates) {
|
|
24785
|
+
for (const childUIStateController of childUIStateControllerArray) {
|
|
24786
|
+
controller.placeChildUIState(
|
|
24787
|
+
childUIStateController,
|
|
24788
|
+
groupUIState,
|
|
24789
|
+
e,
|
|
24790
|
+
);
|
|
24791
|
+
}
|
|
24792
|
+
return;
|
|
24793
|
+
}
|
|
24794
|
+
const monitoredChildren = childUIStateControllerArray.filter(
|
|
24795
|
+
shouldPropagateStateToChild,
|
|
24796
|
+
);
|
|
24797
|
+
const stateByChild = resolvedDistributeChildStates(
|
|
24798
|
+
groupUIState,
|
|
24799
|
+
monitoredChildren,
|
|
24800
|
+
);
|
|
24801
|
+
if (!stateByChild) {
|
|
24802
|
+
return;
|
|
24803
|
+
}
|
|
24804
|
+
for (const childUIStateController of monitoredChildren) {
|
|
24805
|
+
if (!stateByChild.has(childUIStateController)) {
|
|
24806
|
+
// Not named by the answer: left where it is, the way
|
|
24807
|
+
// CANNOT_DERIVE leaves a child a per-child distribute says
|
|
24808
|
+
// nothing about.
|
|
24809
|
+
continue;
|
|
24810
|
+
}
|
|
24811
|
+
if (
|
|
24812
|
+
childUIStateController.hasStateProp &&
|
|
24813
|
+
!childUIStateController.props.signal
|
|
24814
|
+
) {
|
|
24815
|
+
continue;
|
|
24816
|
+
}
|
|
24817
|
+
childUIStateController.setUIState(
|
|
24818
|
+
stateByChild.get(childUIStateController),
|
|
24819
|
+
e,
|
|
24820
|
+
);
|
|
24821
|
+
}
|
|
24822
|
+
},
|
|
24746
24823
|
placeChildUIState: (childUIStateController, groupUIState, e) => {
|
|
24747
24824
|
if (!shouldPropagateStateToChild(childUIStateController)) {
|
|
24748
24825
|
return;
|
|
@@ -24804,13 +24881,7 @@ const useUIGroupStateController = (
|
|
|
24804
24881
|
detail: {},
|
|
24805
24882
|
});
|
|
24806
24883
|
chainEvent(propagateDownEvent, e);
|
|
24807
|
-
|
|
24808
|
-
controller.placeChildUIState(
|
|
24809
|
-
childUIStateController,
|
|
24810
|
-
newUIState,
|
|
24811
|
-
propagateDownEvent,
|
|
24812
|
-
);
|
|
24813
|
-
}
|
|
24884
|
+
controller.placeChildrenUIState(newUIState, propagateDownEvent);
|
|
24814
24885
|
const groupUIState = aggregateGroupUIState(newUIState);
|
|
24815
24886
|
if (e.type === "initial_state_push") {
|
|
24816
24887
|
controller.syncInternalState(groupUIState);
|
|
@@ -24885,11 +24956,21 @@ const useUIGroupStateController = (
|
|
|
24885
24956
|
const initialEvent = new CustomEvent("initial_state_push", {
|
|
24886
24957
|
detail: {},
|
|
24887
24958
|
});
|
|
24888
|
-
|
|
24889
|
-
|
|
24890
|
-
|
|
24891
|
-
|
|
24892
|
-
|
|
24959
|
+
if (resolvedDistributeChildStates) {
|
|
24960
|
+
// A group answering for all its children at once has to be asked
|
|
24961
|
+
// again now that there is one more: what each of them shows may
|
|
24962
|
+
// depend on who else is there.
|
|
24963
|
+
controller.placeChildrenUIState(
|
|
24964
|
+
stateToPlaceChildFrom,
|
|
24965
|
+
initialEvent,
|
|
24966
|
+
);
|
|
24967
|
+
} else {
|
|
24968
|
+
controller.placeChildUIState(
|
|
24969
|
+
childUIStateController,
|
|
24970
|
+
stateToPlaceChildFrom,
|
|
24971
|
+
initialEvent,
|
|
24972
|
+
);
|
|
24973
|
+
}
|
|
24893
24974
|
}
|
|
24894
24975
|
onChange(new CustomEvent(`${childControlType}_mount`), {
|
|
24895
24976
|
notifyExternal: "silent",
|
|
@@ -25064,13 +25145,7 @@ const useUIGroupStateController = (
|
|
|
25064
25145
|
"propagate_down_set_ui_state",
|
|
25065
25146
|
{ detail: {} },
|
|
25066
25147
|
);
|
|
25067
|
-
|
|
25068
|
-
controller.placeChildUIState(
|
|
25069
|
-
childUIStateController,
|
|
25070
|
-
groupUIState,
|
|
25071
|
-
propagateDownEvent,
|
|
25072
|
-
);
|
|
25073
|
-
}
|
|
25148
|
+
controller.placeChildrenUIState(groupUIState, propagateDownEvent);
|
|
25074
25149
|
controller.syncInternalState(groupUIState);
|
|
25075
25150
|
};
|
|
25076
25151
|
if (
|
|
@@ -25361,6 +25436,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
|
|
|
25361
25436
|
return {
|
|
25362
25437
|
controller: facadeUIStateController,
|
|
25363
25438
|
realUIStateController,
|
|
25439
|
+
props,
|
|
25364
25440
|
};
|
|
25365
25441
|
},
|
|
25366
25442
|
// ── update: runs every render after the first ─────────────────────────
|
|
@@ -25374,6 +25450,7 @@ const useUIFacadeStateController = (props, realUIStateController) => {
|
|
|
25374
25450
|
|
|
25375
25451
|
return {
|
|
25376
25452
|
realUIStateController,
|
|
25453
|
+
props,
|
|
25377
25454
|
};
|
|
25378
25455
|
},
|
|
25379
25456
|
);
|
|
@@ -25536,17 +25613,21 @@ const resolveClearedUIState = (controller) => {
|
|
|
25536
25613
|
return "";
|
|
25537
25614
|
};
|
|
25538
25615
|
|
|
25539
|
-
// What a control says when it has nothing to say: no value at all,
|
|
25540
|
-
// array/object a group falls back to while it has no child to aggregate
|
|
25616
|
+
// What a control says when it has nothing to say: no value at all, the empty
|
|
25617
|
+
// array/object a group falls back to while it has no child to aggregate — or a
|
|
25618
|
+
// shape whose every part is itself nothing. That last one is what a group with
|
|
25619
|
+
// an aggregate of its own produces before its children have arrived
|
|
25620
|
+
// (`{ mode: undefined, levels: [] }` has two keys and says nothing), and
|
|
25621
|
+
// reading it as an answer is how a popup opening empties the row above it.
|
|
25541
25622
|
const uiStateHoldsNothing = (uiState) => {
|
|
25542
25623
|
if (uiState === undefined) {
|
|
25543
25624
|
return true;
|
|
25544
25625
|
}
|
|
25545
25626
|
if (Array.isArray(uiState)) {
|
|
25546
|
-
return uiState.
|
|
25627
|
+
return uiState.every(uiStateHoldsNothing);
|
|
25547
25628
|
}
|
|
25548
25629
|
if (uiState !== null && typeof uiState === "object") {
|
|
25549
|
-
return Object.
|
|
25630
|
+
return Object.values(uiState).every(uiStateHoldsNothing);
|
|
25550
25631
|
}
|
|
25551
25632
|
return false;
|
|
25552
25633
|
};
|
|
@@ -26420,7 +26501,11 @@ const createControlInfo = (props, {
|
|
|
26420
26501
|
if (typeProp === "checkbox" || typeProp === "radio") {
|
|
26421
26502
|
statePropName = "checked";
|
|
26422
26503
|
defaultStatePropName = "defaultChecked";
|
|
26423
|
-
|
|
26504
|
+
// "on" is what HTML sends for a checkbox given no value of its own — the
|
|
26505
|
+
// default of an ABSENT prop, not of a falsy one: `value={false}` and
|
|
26506
|
+
// `value={0}` are values, and two rows holding true and false are how a
|
|
26507
|
+
// list asks a yes/no question.
|
|
26508
|
+
value = props.value === undefined ? "on" : props.value;
|
|
26424
26509
|
signalHoldsChecked = true;
|
|
26425
26510
|
if (signal) {
|
|
26426
26511
|
if (props.defaultChecked) {
|
|
@@ -26605,6 +26690,7 @@ const useControlgroupProps = (props, {
|
|
|
26605
26690
|
childControlFilter,
|
|
26606
26691
|
aggregateChildStates,
|
|
26607
26692
|
distributeChildUIState,
|
|
26693
|
+
distributeChildStates,
|
|
26608
26694
|
wantRequesterButtonState,
|
|
26609
26695
|
uiActionInternal,
|
|
26610
26696
|
allowCapture = false,
|
|
@@ -26618,6 +26704,7 @@ const useControlgroupProps = (props, {
|
|
|
26618
26704
|
childControlFilter,
|
|
26619
26705
|
aggregateChildStates,
|
|
26620
26706
|
distributeChildUIState,
|
|
26707
|
+
distributeChildStates,
|
|
26621
26708
|
wantRequesterButtonState,
|
|
26622
26709
|
uiActionInternal,
|
|
26623
26710
|
allowCapture,
|
|
@@ -26832,7 +26919,8 @@ const useInteractiveProps = (props, {
|
|
|
26832
26919
|
disabled,
|
|
26833
26920
|
required,
|
|
26834
26921
|
readOnly,
|
|
26835
|
-
loading
|
|
26922
|
+
loading,
|
|
26923
|
+
optimistic
|
|
26836
26924
|
} = props;
|
|
26837
26925
|
const disabledResolved = disabled || controlDisabled;
|
|
26838
26926
|
const requiredResolved = required || controlRequired;
|
|
@@ -26847,14 +26935,22 @@ const useInteractiveProps = (props, {
|
|
|
26847
26935
|
// but cannot be taken.
|
|
26848
26936
|
const readOnlyFromParentMaxLengthGuard = Boolean(uiStateController.parentUIStateController?.isChildBlockedByMaxLengthGuard?.(uiStateController));
|
|
26849
26937
|
const readOnlyBase = readOnly || controlReadOnly || loadingBase || readOnlyFromParentMaxLengthGuard || controlInfo.readOnlyUncontrolled;
|
|
26850
|
-
|
|
26851
|
-
|
|
26938
|
+
// An optimistic control trusts its action to succeed: the state the user
|
|
26939
|
+
// just set stays visible and interactive while the action runs — no
|
|
26940
|
+
// loading, no readonly. On failure resetOnError rolls the state back and
|
|
26941
|
+
// the error callout says why.
|
|
26942
|
+
const actionLoading = optimistic ? false : actionStatus.loading;
|
|
26943
|
+
const loadingResolved = loadingBase || actionLoading;
|
|
26944
|
+
const readOnlyResolved = readOnlyBase || actionLoading;
|
|
26852
26945
|
// Both halves of "busy" that do not come from the bound action, kept apart
|
|
26853
26946
|
// from each other and from it: BUSY_CONSTRAINT answers each from its own
|
|
26854
26947
|
// live source rather than from the rendered aria-busy, which conflates all
|
|
26855
26948
|
// three and is a frame behind. See its own comment.
|
|
26856
26949
|
uiStateController.loadingFromOwnProp = Boolean(loading);
|
|
26857
26950
|
uiStateController.loadingFromParent = loadingFromParent;
|
|
26951
|
+
// Read by BUSY_CONSTRAINT: an optimistic control stays interactive while
|
|
26952
|
+
// its bound action runs (a new toggle replaces the run instead of waiting).
|
|
26953
|
+
uiStateController.optimistic = Boolean(optimistic);
|
|
26858
26954
|
Object.assign(controlHostProps, {
|
|
26859
26955
|
"required": requiredResolved,
|
|
26860
26956
|
"aria-busy": loadingResolved ? "true" : "false",
|
|
@@ -27020,7 +27116,8 @@ const useInteractiveProps = (props, {
|
|
|
27020
27116
|
onActionEnd,
|
|
27021
27117
|
resetOnCancel,
|
|
27022
27118
|
resetOnAbort,
|
|
27023
|
-
resetOnError
|
|
27119
|
+
resetOnError,
|
|
27120
|
+
optimistic
|
|
27024
27121
|
} = props;
|
|
27025
27122
|
Object.assign(controlHostProps, {
|
|
27026
27123
|
onFocus: e => {
|
|
@@ -27075,14 +27172,80 @@ const useInteractiveProps = (props, {
|
|
|
27075
27172
|
// special case for the use case where form.requestSubmit is called
|
|
27076
27173
|
e.detail.action = boundAction;
|
|
27077
27174
|
}
|
|
27175
|
+
// An optimistic control stays interactive while its action runs, so a
|
|
27176
|
+
// second request can arrive mid-run. The server must receive them in
|
|
27177
|
+
// order: the new request is queued (latest wins — the bound action
|
|
27178
|
+
// reads the UI state signal at run time, so what goes out is always
|
|
27179
|
+
// the current state) and goes out once the running one has truly
|
|
27180
|
+
// settled (see onnavi_action_start). The running action is asked to
|
|
27181
|
+
// abort — its outcome is already outdated — but aborting is only a
|
|
27182
|
+
// resource optimization (a fetch wired to the action's signal gets
|
|
27183
|
+
// cancelled): the server may have done the work anyway, and only the
|
|
27184
|
+
// settlement says which. So even aborted, the underlying work is
|
|
27185
|
+
// awaited before the queued request runs. See the abort section in
|
|
27186
|
+
// docs/actions.md.
|
|
27187
|
+
if (optimistic && uiStateController.actionInFlight) {
|
|
27188
|
+
debugAction(e, `queueing action (one already in flight)`);
|
|
27189
|
+
uiStateController.queuedActionAllowedEvent = e;
|
|
27190
|
+
// The instance captured at navi_action_start, NOT boundAction:
|
|
27191
|
+
// boundAction is a proxy following the UI state signal, and the UI
|
|
27192
|
+
// state has already moved to the new value by now — the proxy would
|
|
27193
|
+
// resolve to the instance for that new value, which is not the one
|
|
27194
|
+
// running.
|
|
27195
|
+
uiStateController.runningAction?.abort(`superseded by a newer request on this control`);
|
|
27196
|
+
return;
|
|
27197
|
+
}
|
|
27078
27198
|
debugAction(e, `executing action ${e.detail.action.callSource}`);
|
|
27079
27199
|
executeAction(e);
|
|
27080
27200
|
},
|
|
27081
27201
|
onnavi_action_start: e => {
|
|
27202
|
+
// The run this control currently waits on, identified by the
|
|
27203
|
+
// navi_action_allowed event that launched it (unique per execution,
|
|
27204
|
+
// carried by every navi_action_* event of that run).
|
|
27205
|
+
uiStateController.pendingActionEvent = e.detail.event;
|
|
27206
|
+
uiStateController.actionInFlight = true;
|
|
27207
|
+
// The very instance this run uses, resolved now — while the UI state
|
|
27208
|
+
// still holds the value the run was made for. detail.action may be a
|
|
27209
|
+
// proxy following that state, and by the time anyone wants to abort
|
|
27210
|
+
// this run (see the optimistic queue above), the state — and the
|
|
27211
|
+
// proxy's resolution — will have moved on.
|
|
27212
|
+
const runAction = e.detail.action;
|
|
27213
|
+
uiStateController.runningAction = runAction.getCurrentAction?.() ?? runAction;
|
|
27214
|
+
// Fires when the run's underlying work has settled — even for an
|
|
27215
|
+
// aborted run, whose promise is awaited to completion (see
|
|
27216
|
+
// performRun in actions.js) — which is exactly what "the server is
|
|
27217
|
+
// done with it" means, and therefore when the queued request may go.
|
|
27218
|
+
e.detail.addSideEffect(outcome => {
|
|
27219
|
+
uiStateController.actionInFlight = false;
|
|
27220
|
+
uiStateController.runningAction = null;
|
|
27221
|
+
const queuedEvent = uiStateController.queuedActionAllowedEvent;
|
|
27222
|
+
uiStateController.queuedActionAllowedEvent = null;
|
|
27223
|
+
if (!queuedEvent) {
|
|
27224
|
+
return;
|
|
27225
|
+
}
|
|
27226
|
+
if (outcome.error) {
|
|
27227
|
+
// A failure abandons the queue: the UI is rolled back to the last
|
|
27228
|
+
// known state (resetOnError above), and what was queued was built
|
|
27229
|
+
// on top of the state that just failed.
|
|
27230
|
+
return;
|
|
27231
|
+
}
|
|
27232
|
+
// A microtask later, not right here: this runs inside the batch()
|
|
27233
|
+
// that settles the action (see watchActionCompletion for the same
|
|
27234
|
+
// constraint).
|
|
27235
|
+
queueMicrotask(() => {
|
|
27236
|
+
executeAction(queuedEvent);
|
|
27237
|
+
});
|
|
27238
|
+
});
|
|
27082
27239
|
onActionStart?.(e);
|
|
27083
27240
|
},
|
|
27084
27241
|
onnavi_action_abort: e => {
|
|
27085
|
-
|
|
27242
|
+
// Only an abort that leaves the control with nothing left to do may
|
|
27243
|
+
// reset the UI state. An abort whose run was superseded — a queued
|
|
27244
|
+
// request waits behind it (optimistic), or a newer run already
|
|
27245
|
+
// started — must leave the state alone: it belongs to the newer
|
|
27246
|
+
// request, resetting would throw away what the user just set.
|
|
27247
|
+
const superseded = Boolean(uiStateController.queuedActionAllowedEvent) || e.detail.event !== uiStateController.pendingActionEvent;
|
|
27248
|
+
if (resetOnAbort && !superseded) {
|
|
27086
27249
|
dispatchRequestResetUIState(e.currentTarget, e);
|
|
27087
27250
|
}
|
|
27088
27251
|
onActionAborted?.(e);
|
|
@@ -27180,7 +27343,7 @@ const getAssociatedLabels = element => {
|
|
|
27180
27343
|
return Array.from(element.labels);
|
|
27181
27344
|
};
|
|
27182
27345
|
|
|
27183
|
-
installImportMetaCssBuild(import.meta);const css$
|
|
27346
|
+
installImportMetaCssBuild(import.meta);const css$Z = /* css */`
|
|
27184
27347
|
@layer navi {
|
|
27185
27348
|
.navi_button {
|
|
27186
27349
|
--button-border-radius: var(--navi-control-border-radius);
|
|
@@ -27611,7 +27774,7 @@ installImportMetaCssBuild(import.meta);const css$Y = /* css */`
|
|
|
27611
27774
|
}
|
|
27612
27775
|
`;
|
|
27613
27776
|
const ButtonUI = props => {
|
|
27614
|
-
import.meta.css = [css$
|
|
27777
|
+
import.meta.css = [css$Z, "@jsenv/navi/src/control/input/button_ui.jsx"];
|
|
27615
27778
|
const {
|
|
27616
27779
|
ref,
|
|
27617
27780
|
// href/link
|
|
@@ -29322,7 +29485,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
29322
29485
|
* reaches the real container.
|
|
29323
29486
|
*/
|
|
29324
29487
|
let openLocalDialogCount = 0;
|
|
29325
|
-
const css$
|
|
29488
|
+
const css$Y = /* css */`
|
|
29326
29489
|
@layer navi {
|
|
29327
29490
|
.navi_dialog {
|
|
29328
29491
|
/* Min gap between the dialog and the edges of its container. Written
|
|
@@ -29906,7 +30069,7 @@ const css$X = /* css */`
|
|
|
29906
30069
|
* @param {import("ignore:preact").ComponentChildren} props.children
|
|
29907
30070
|
*/
|
|
29908
30071
|
const Dialog = props => {
|
|
29909
|
-
import.meta.css = [css$
|
|
30072
|
+
import.meta.css = [css$Y, "@jsenv/navi/src/layout/dialog.jsx"];
|
|
29910
30073
|
if (props.openController) {
|
|
29911
30074
|
return jsx(ControlledDialog, {
|
|
29912
30075
|
...props
|
|
@@ -30898,7 +31061,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
30898
31061
|
* and applied.
|
|
30899
31062
|
*/
|
|
30900
31063
|
let openLocalPopoverCount = 0;
|
|
30901
|
-
const css$
|
|
31064
|
+
const css$X = /* css */`
|
|
30902
31065
|
@layer navi {
|
|
30903
31066
|
.navi_popover {
|
|
30904
31067
|
/* soft: user-configurable preferred max-height. Kept as a *default*
|
|
@@ -31354,7 +31517,7 @@ const css$W = /* css */`
|
|
|
31354
31517
|
* @param {import("ignore:preact").ComponentChildren} props.children
|
|
31355
31518
|
*/
|
|
31356
31519
|
const Popover = props => {
|
|
31357
|
-
import.meta.css = [css$
|
|
31520
|
+
import.meta.css = [css$X, "@jsenv/navi/src/layout/popover.jsx"];
|
|
31358
31521
|
if (props.openController) {
|
|
31359
31522
|
return jsx(ControlledPopover, {
|
|
31360
31523
|
...props
|
|
@@ -32382,7 +32545,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
32382
32545
|
* event, and a caller replacing the body entirely then has one protocol to
|
|
32383
32546
|
* follow — `--navi-confirm` for yes, anything that closes for no.
|
|
32384
32547
|
*/
|
|
32385
|
-
const css$
|
|
32548
|
+
const css$W = /* css */`
|
|
32386
32549
|
/* The width lives on the body rather than on the popup, so that custom
|
|
32387
32550
|
content (which replaces this body entirely) sizes itself instead of
|
|
32388
32551
|
inheriting a ceiling meant for a sentence-long question. */
|
|
@@ -32519,7 +32682,7 @@ const ConfirmPopup = ({
|
|
|
32519
32682
|
onAnswer,
|
|
32520
32683
|
onClosed
|
|
32521
32684
|
}) => {
|
|
32522
|
-
import.meta.css = [css$
|
|
32685
|
+
import.meta.css = [css$W, "@jsenv/navi/src/action/confirm_popup.jsx"];
|
|
32523
32686
|
const {
|
|
32524
32687
|
mode,
|
|
32525
32688
|
confirmLabel,
|
|
@@ -32603,7 +32766,7 @@ const defaultBody = (message, {
|
|
|
32603
32766
|
});
|
|
32604
32767
|
};
|
|
32605
32768
|
|
|
32606
|
-
installImportMetaCssBuild(import.meta);const css$
|
|
32769
|
+
installImportMetaCssBuild(import.meta);const css$V = /* css */`
|
|
32607
32770
|
.action_error {
|
|
32608
32771
|
margin-top: 0;
|
|
32609
32772
|
margin-bottom: 20px;
|
|
@@ -32628,7 +32791,7 @@ const ActionRenderer = ({
|
|
|
32628
32791
|
children,
|
|
32629
32792
|
disabled
|
|
32630
32793
|
}) => {
|
|
32631
|
-
import.meta.css = [css$
|
|
32794
|
+
import.meta.css = [css$V, "@jsenv/navi/src/action/action_renderer.jsx"];
|
|
32632
32795
|
if (action === undefined) {
|
|
32633
32796
|
throw new Error("ActionRenderer requires an action to render, but none was provided.");
|
|
32634
32797
|
}
|
|
@@ -38372,7 +38535,7 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
|
|
|
38372
38535
|
// the root pictures must NOT move (they carry the whole viewport, blank bands
|
|
38373
38536
|
// included).
|
|
38374
38537
|
|
|
38375
|
-
const css$
|
|
38538
|
+
const css$U = /* css */`
|
|
38376
38539
|
/* The marked region is a picture of its own for the length of a transition of
|
|
38377
38540
|
OURS, and only then — the name is what makes the pages a picture the
|
|
38378
38541
|
movement below can carry.
|
|
@@ -38801,7 +38964,7 @@ const RouteTransitionArea = ({
|
|
|
38801
38964
|
children,
|
|
38802
38965
|
...rest
|
|
38803
38966
|
}) => {
|
|
38804
|
-
import.meta.css = [css$
|
|
38967
|
+
import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
|
|
38805
38968
|
const props = {
|
|
38806
38969
|
...rest,
|
|
38807
38970
|
[TRANSITION_AREA_ATTRIBUTE]: ""
|
|
@@ -38858,7 +39021,7 @@ const RouteTransitionArea = ({
|
|
|
38858
39021
|
* @returns {() => void} remove this relation.
|
|
38859
39022
|
*/
|
|
38860
39023
|
const defineRouteTransition = (from, to, transition) => {
|
|
38861
|
-
import.meta.css = [css$
|
|
39024
|
+
import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
|
|
38862
39025
|
const {
|
|
38863
39026
|
type,
|
|
38864
39027
|
duration
|
|
@@ -38894,7 +39057,7 @@ const defineRouteTransition = (from, to, transition) => {
|
|
|
38894
39057
|
* @returns {() => void} remove this default.
|
|
38895
39058
|
*/
|
|
38896
39059
|
const defineRouteDefaultTransition = transition => {
|
|
38897
|
-
import.meta.css = [css$
|
|
39060
|
+
import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
|
|
38898
39061
|
const value = normalizeTransition(transition);
|
|
38899
39062
|
defaultTransition = value;
|
|
38900
39063
|
return () => {
|
|
@@ -39495,7 +39658,7 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
|
|
|
39495
39658
|
const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
|
|
39496
39659
|
// The name the box wears while it travels, and only then (see nameForTravel).
|
|
39497
39660
|
const TRAVEL_NAME = "navi-route-travel";
|
|
39498
|
-
const css$
|
|
39661
|
+
const css$T = /* css */`
|
|
39499
39662
|
/* The name that makes the page inside this box a picture of its own during a
|
|
39500
39663
|
transition — rather than part of the one big picture the document takes, so
|
|
39501
39664
|
the two pages can move past each other while everything else stays where it
|
|
@@ -39885,7 +40048,7 @@ const RouteTravel = ({
|
|
|
39885
40048
|
children,
|
|
39886
40049
|
...rest
|
|
39887
40050
|
}) => {
|
|
39888
|
-
import.meta.css = [css$
|
|
40051
|
+
import.meta.css = [css$T, "@jsenv/navi/src/nav/route_travel.jsx"];
|
|
39889
40052
|
const elementRef = useRef();
|
|
39890
40053
|
const gestureRef = useRef(null);
|
|
39891
40054
|
// The travel in hand: the transition keeping the picture of the page being
|
|
@@ -42447,7 +42610,7 @@ const PhoneSvg = () => {
|
|
|
42447
42610
|
};
|
|
42448
42611
|
|
|
42449
42612
|
installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
|
|
42450
|
-
const css$
|
|
42613
|
+
const css$S = /* css */`
|
|
42451
42614
|
.navi_text_anchor {
|
|
42452
42615
|
vertical-align: baseline;
|
|
42453
42616
|
user-select: none;
|
|
@@ -42482,7 +42645,7 @@ const TextAnchor = ({
|
|
|
42482
42645
|
textSize,
|
|
42483
42646
|
lineLayout
|
|
42484
42647
|
}) => {
|
|
42485
|
-
import.meta.css = [css$
|
|
42648
|
+
import.meta.css = [css$S, "@jsenv/navi/src/text/text_anchor.jsx"];
|
|
42486
42649
|
const anchorRef = useRef();
|
|
42487
42650
|
|
|
42488
42651
|
// Plain useLayoutEffect would also fire while an ancestor dialog/popover
|
|
@@ -42597,7 +42760,7 @@ const computeTopOffset = ({
|
|
|
42597
42760
|
};
|
|
42598
42761
|
const charTopCanvas = document.createElement("canvas");
|
|
42599
42762
|
|
|
42600
|
-
installImportMetaCssBuild(import.meta);const css$
|
|
42763
|
+
installImportMetaCssBuild(import.meta);const css$R = /* css */`
|
|
42601
42764
|
@layer navi {
|
|
42602
42765
|
/* Ensure data attributes from box.jsx can win to update display */
|
|
42603
42766
|
.navi_icon {
|
|
@@ -42755,7 +42918,7 @@ const Icon = ({
|
|
|
42755
42918
|
fillLine,
|
|
42756
42919
|
...props
|
|
42757
42920
|
}) => {
|
|
42758
|
-
import.meta.css = [css$
|
|
42921
|
+
import.meta.css = [css$R, "@jsenv/navi/src/text/icon.jsx"];
|
|
42759
42922
|
const innerChildren = href ? jsx("svg", {
|
|
42760
42923
|
width: "100%",
|
|
42761
42924
|
height: "100%",
|
|
@@ -42917,7 +43080,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
|
|
|
42917
43080
|
});
|
|
42918
43081
|
};
|
|
42919
43082
|
|
|
42920
|
-
installImportMetaCssBuild(import.meta);const css$
|
|
43083
|
+
installImportMetaCssBuild(import.meta);const css$Q = /* css */`
|
|
42921
43084
|
@layer navi {
|
|
42922
43085
|
.navi_link {
|
|
42923
43086
|
--link-border-radius: unset;
|
|
@@ -43379,7 +43542,7 @@ Object.assign(PSEUDO_CLASSES, {
|
|
|
43379
43542
|
* @param {boolean} [props.readOnly]
|
|
43380
43543
|
*/
|
|
43381
43544
|
const Link = props => {
|
|
43382
|
-
import.meta.css = [css$
|
|
43545
|
+
import.meta.css = [css$Q, "@jsenv/navi/src/nav/link/link.jsx"];
|
|
43383
43546
|
if (props.route) {
|
|
43384
43547
|
return jsx(LinkWithRoute, {
|
|
43385
43548
|
...props
|
|
@@ -43702,7 +43865,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
43702
43865
|
* https://dribbble.com/search/tabs
|
|
43703
43866
|
*/
|
|
43704
43867
|
let navCount = 0;
|
|
43705
|
-
const css$
|
|
43868
|
+
const css$P = /* css */`
|
|
43706
43869
|
@layer navi {
|
|
43707
43870
|
.navi_nav {
|
|
43708
43871
|
--nav-border: none;
|
|
@@ -43985,7 +44148,7 @@ const Nav = ({
|
|
|
43985
44148
|
slideContainer,
|
|
43986
44149
|
...props
|
|
43987
44150
|
}) => {
|
|
43988
|
-
import.meta.css = [css$
|
|
44151
|
+
import.meta.css = [css$P, "@jsenv/navi/src/nav/link/nav.jsx"];
|
|
43989
44152
|
const defaultRef = useRef();
|
|
43990
44153
|
props.ref = props.ref || defaultRef;
|
|
43991
44154
|
const navRef = props.ref;
|
|
@@ -44512,7 +44675,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
44512
44675
|
* Border width participates in layout (it is added to the tab and page
|
|
44513
44676
|
* padding): a thick border grows the binder rather than eating into the text.
|
|
44514
44677
|
*/
|
|
44515
|
-
const css$
|
|
44678
|
+
const css$O = /* css */`
|
|
44516
44679
|
@layer navi {
|
|
44517
44680
|
.navi_binder {
|
|
44518
44681
|
--binder-border-width: var(--navi-control-border-width);
|
|
@@ -44825,7 +44988,7 @@ const Binder = ({
|
|
|
44825
44988
|
pagePadding,
|
|
44826
44989
|
...props
|
|
44827
44990
|
}) => {
|
|
44828
|
-
import.meta.css = [css$
|
|
44991
|
+
import.meta.css = [css$O, "@jsenv/navi/src/nav/binder/binder.jsx"];
|
|
44829
44992
|
const items = toChildArray(children).map((child, index) => {
|
|
44830
44993
|
const {
|
|
44831
44994
|
value: itemValue,
|
|
@@ -45312,7 +45475,7 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
45312
45475
|
* added to the size asked for exactly like the notch inset is, so the
|
|
45313
45476
|
* content still gets the size the prop names.
|
|
45314
45477
|
*/
|
|
45315
|
-
const css$
|
|
45478
|
+
const css$N = /* css */`
|
|
45316
45479
|
@layer navi {
|
|
45317
45480
|
:root {
|
|
45318
45481
|
--navi-fixed-bar-width: 56px;
|
|
@@ -45460,7 +45623,7 @@ const FixedBar = ({
|
|
|
45460
45623
|
border = true,
|
|
45461
45624
|
...props
|
|
45462
45625
|
}) => {
|
|
45463
|
-
import.meta.css = [css$
|
|
45626
|
+
import.meta.css = [css$N, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
|
|
45464
45627
|
const defaultRef = useRef();
|
|
45465
45628
|
props.ref = props.ref || defaultRef;
|
|
45466
45629
|
// Said with the width the border rule reads rather than with an attribute of
|
|
@@ -45554,7 +45717,7 @@ const FixedBar = ({
|
|
|
45554
45717
|
// Subpixel layout rounds rectangles up on boxes that fit exactly.
|
|
45555
45718
|
const OVERFLOW_TOLERANCE = 1;
|
|
45556
45719
|
|
|
45557
|
-
const css$
|
|
45720
|
+
const css$M = /* css */ `
|
|
45558
45721
|
[data-navi-overflow-x] {
|
|
45559
45722
|
outline: 2px dashed #e74c3c;
|
|
45560
45723
|
outline-offset: -2px;
|
|
@@ -45578,7 +45741,7 @@ const detectHorizontalOverflow = ({
|
|
|
45578
45741
|
let styleEl = null;
|
|
45579
45742
|
if (highlight) {
|
|
45580
45743
|
styleEl = document.createElement("style");
|
|
45581
|
-
styleEl.textContent = css$
|
|
45744
|
+
styleEl.textContent = css$M;
|
|
45582
45745
|
document.head.appendChild(styleEl);
|
|
45583
45746
|
}
|
|
45584
45747
|
|
|
@@ -45733,8 +45896,7 @@ const useFocusGroup = (
|
|
|
45733
45896
|
};
|
|
45734
45897
|
|
|
45735
45898
|
installImportMetaCssBuild(import.meta);const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
|
|
45736
|
-
const
|
|
45737
|
-
const css$K = /* css */`
|
|
45899
|
+
const css$L = /* css */`
|
|
45738
45900
|
.navi_summary_marker {
|
|
45739
45901
|
width: 1em;
|
|
45740
45902
|
height: 1em;
|
|
@@ -45758,22 +45920,31 @@ const css$K = /* css */`
|
|
|
45758
45920
|
}
|
|
45759
45921
|
}
|
|
45760
45922
|
|
|
45761
|
-
|
|
45762
|
-
|
|
45763
|
-
|
|
45764
|
-
|
|
45765
|
-
|
|
45766
|
-
animation-fill-mode: forwards;
|
|
45923
|
+
/* One chevron, rotated: the transition only ever plays on a direction
|
|
45924
|
+
change, so the first paint shows the resting direction with no
|
|
45925
|
+
movement. */
|
|
45926
|
+
.navi_summary_marker_arrow_group {
|
|
45927
|
+
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
45767
45928
|
|
|
45768
|
-
&[data-
|
|
45769
|
-
|
|
45929
|
+
&[data-direction="right"] {
|
|
45930
|
+
transform: rotate(0deg);
|
|
45770
45931
|
}
|
|
45771
|
-
|
|
45772
|
-
|
|
45773
|
-
|
|
45932
|
+
&[data-direction="down"] {
|
|
45933
|
+
transform: rotate(90deg);
|
|
45934
|
+
}
|
|
45935
|
+
&[data-direction="up"] {
|
|
45936
|
+
transform: rotate(-90deg);
|
|
45937
|
+
}
|
|
45938
|
+
&[data-direction="left"] {
|
|
45939
|
+
transform: rotate(180deg);
|
|
45774
45940
|
}
|
|
45775
45941
|
}
|
|
45776
45942
|
|
|
45943
|
+
.navi_summary_marker_arrow {
|
|
45944
|
+
opacity: 1;
|
|
45945
|
+
transition: opacity 0.3s ease-in-out;
|
|
45946
|
+
}
|
|
45947
|
+
|
|
45777
45948
|
&[data-loading] {
|
|
45778
45949
|
.navi_summary_marker_loading_container {
|
|
45779
45950
|
transform: scale(1);
|
|
@@ -45798,39 +45969,25 @@ const css$K = /* css */`
|
|
|
45798
45969
|
stroke-dashoffset: -2010;
|
|
45799
45970
|
}
|
|
45800
45971
|
}
|
|
45801
|
-
@keyframes morph-to-down {
|
|
45802
|
-
from {
|
|
45803
|
-
d: path("${rightArrowPath}");
|
|
45804
|
-
}
|
|
45805
|
-
to {
|
|
45806
|
-
d: path("${downArrowPath}");
|
|
45807
|
-
}
|
|
45808
|
-
}
|
|
45809
|
-
@keyframes morph-to-right {
|
|
45810
|
-
from {
|
|
45811
|
-
d: path("${downArrowPath}");
|
|
45812
|
-
}
|
|
45813
|
-
to {
|
|
45814
|
-
d: path("${rightArrowPath}");
|
|
45815
|
-
}
|
|
45816
|
-
}
|
|
45817
45972
|
`;
|
|
45973
|
+
|
|
45974
|
+
/**
|
|
45975
|
+
* @type {import("ignore:preact").FunctionComponent<{
|
|
45976
|
+
* open?: boolean,
|
|
45977
|
+
* loading?: boolean,
|
|
45978
|
+
* openDirection?: "down" | "up" | "left",
|
|
45979
|
+
* }>}
|
|
45980
|
+
* @param openDirection - Where the chevron points while open; closed always
|
|
45981
|
+
* points right. "down" fits content revealed below (the <details> shape),
|
|
45982
|
+
* "up" content revealed above, "left" content revealed beside.
|
|
45983
|
+
*/
|
|
45818
45984
|
const SummaryMarker = ({
|
|
45819
45985
|
open,
|
|
45820
|
-
loading
|
|
45986
|
+
loading,
|
|
45987
|
+
openDirection = "down"
|
|
45821
45988
|
}) => {
|
|
45822
|
-
import.meta.css = [css$
|
|
45989
|
+
import.meta.css = [css$L, "@jsenv/navi/src/control/details/summary_marker.jsx"];
|
|
45823
45990
|
const showLoading = useDebounceTrue(loading, 300);
|
|
45824
|
-
const mountedRef = useRef(false);
|
|
45825
|
-
const prevOpenRef = useRef(open);
|
|
45826
|
-
useLayoutEffect(() => {
|
|
45827
|
-
mountedRef.current = true;
|
|
45828
|
-
return () => {
|
|
45829
|
-
mountedRef.current = false;
|
|
45830
|
-
};
|
|
45831
|
-
}, []);
|
|
45832
|
-
const shouldAnimate = mountedRef.current && prevOpenRef.current !== open;
|
|
45833
|
-
prevOpenRef.current = open;
|
|
45834
45991
|
return jsx("span", {
|
|
45835
45992
|
className: "navi_summary_marker",
|
|
45836
45993
|
"data-loading": showLoading ? "" : undefined,
|
|
@@ -45861,19 +46018,20 @@ const SummaryMarker = ({
|
|
|
45861
46018
|
strokeDasharray: "503 1507"
|
|
45862
46019
|
})]
|
|
45863
46020
|
}), jsx("g", {
|
|
46021
|
+
className: "navi_summary_marker_arrow_group",
|
|
46022
|
+
"data-direction": open ? openDirection : "right",
|
|
45864
46023
|
"transform-origin": "480px -480px",
|
|
45865
46024
|
children: jsx("path", {
|
|
45866
46025
|
className: "navi_summary_marker_arrow",
|
|
45867
46026
|
fill: "currentColor",
|
|
45868
|
-
|
|
45869
|
-
d: open ? downArrowPath : rightArrowPath
|
|
46027
|
+
d: rightArrowPath
|
|
45870
46028
|
})
|
|
45871
46029
|
})]
|
|
45872
46030
|
})
|
|
45873
46031
|
});
|
|
45874
46032
|
};
|
|
45875
46033
|
|
|
45876
|
-
installImportMetaCssBuild(import.meta);const css$
|
|
46034
|
+
installImportMetaCssBuild(import.meta);const css$K = /* css */`
|
|
45877
46035
|
.navi_details {
|
|
45878
46036
|
position: relative;
|
|
45879
46037
|
z-index: 1;
|
|
@@ -45919,7 +46077,7 @@ const Details = props => {
|
|
|
45919
46077
|
return details;
|
|
45920
46078
|
};
|
|
45921
46079
|
const DetailsField = props => {
|
|
45922
|
-
import.meta.css = [css$
|
|
46080
|
+
import.meta.css = [css$K, "@jsenv/navi/src/control/details/details.jsx"];
|
|
45923
46081
|
const {
|
|
45924
46082
|
ref,
|
|
45925
46083
|
persists,
|
|
@@ -46075,12 +46233,865 @@ const DetailsFieldContent = ({
|
|
|
46075
46233
|
children
|
|
46076
46234
|
}) => {
|
|
46077
46235
|
const action = useContext(ActionContext);
|
|
46236
|
+
if (!action) {
|
|
46237
|
+
return children;
|
|
46238
|
+
}
|
|
46078
46239
|
return jsx(ActionRenderer, {
|
|
46079
46240
|
action: action,
|
|
46080
46241
|
children: children
|
|
46081
46242
|
});
|
|
46082
46243
|
};
|
|
46083
46244
|
|
|
46245
|
+
installImportMetaCssBuild(import.meta);/**
|
|
46246
|
+
* Expandable: an in-flow disclosure — a UI part that reveals a content part.
|
|
46247
|
+
* It covers the same ground as <Details> with structural differences:
|
|
46248
|
+
*
|
|
46249
|
+
* - the two parts are explicit and free to order/orient:
|
|
46250
|
+
*
|
|
46251
|
+
* <Expandable>
|
|
46252
|
+
* <Expandable.UI>See more</Expandable.UI>
|
|
46253
|
+
* <Expandable.Content>…</Expandable.Content>
|
|
46254
|
+
* </Expandable>
|
|
46255
|
+
*
|
|
46256
|
+
* Content after UI expands below (the <details> shape), Content before UI
|
|
46257
|
+
* expands above; `layout="column"` puts the parts side by side (sharing
|
|
46258
|
+
* their height), the content then expanding horizontally. The marker
|
|
46259
|
+
* chevron follows: it points right while closed and toward where the
|
|
46260
|
+
* content went while open (down, up, or left). The common shape has a
|
|
46261
|
+
* shorthand: `ui` prop + children as content.
|
|
46262
|
+
* - the UI part is the focusable toggle itself (role button, Space/Enter,
|
|
46263
|
+
* arrow keys) and accepts any markup: controls inside it keep their own
|
|
46264
|
+
* behavior, the marker is purely decorative.
|
|
46265
|
+
*
|
|
46266
|
+
* Reach for it knowingly: expanding in-flow SHIFTS the layout — everything
|
|
46267
|
+
* below (or beside) moves when it opens. A Popover, Dialog, Picker or Callout
|
|
46268
|
+
* answers the same click on its own layer, moving nothing, which is usually
|
|
46269
|
+
* the better UX: a layout that stays where it is reads and operates better —
|
|
46270
|
+
* all the more on small screens, mobile first of all, where the shift can
|
|
46271
|
+
* push most of the page away. Expandable is for content that genuinely
|
|
46272
|
+
* belongs in the flow (a tree, a changelog, a settings group read top to
|
|
46273
|
+
* bottom).
|
|
46274
|
+
*
|
|
46275
|
+
* What <details> gives for free is rebuilt here:
|
|
46276
|
+
* - a "toggle" event (a real ToggleEvent when the browser has it) dispatched on
|
|
46277
|
+
* the root whenever the state actually changes — but never on mount, unlike
|
|
46278
|
+
* the native one (see the workaround comment in details.jsx);
|
|
46279
|
+
* - `--navi-toggle`/`--navi-open`/`--navi-close` commands work against it: the
|
|
46280
|
+
* root and the UI part carry `aria-expanded` (what the command system reads)
|
|
46281
|
+
* and answer the `navi_command`/`navi_request_open`/`navi_request_close`
|
|
46282
|
+
* events.
|
|
46283
|
+
*
|
|
46284
|
+
* Content is not built until the first expansion and stays built afterwards —
|
|
46285
|
+
* same policy, same prop names as popups (see popup_content_mount.js):
|
|
46286
|
+
* `mountWhenClosed` builds it right away, `unmountWhenClosed` throws it away
|
|
46287
|
+
* once the collapse settles (so a closing animation still plays on real
|
|
46288
|
+
* content).
|
|
46289
|
+
*
|
|
46290
|
+
* The animation is a REVEAL, not a resize: the expandable's own footprint
|
|
46291
|
+
* grows/shrinks progressively (the content's grid track interpolates
|
|
46292
|
+
* 0fr <-> 1fr — rows for the stacked layout, columns for `layout="column"`),
|
|
46293
|
+
* but the content inside is laid out at its final size for the whole movement
|
|
46294
|
+
* (its animated dimension is frozen to the measured final value, see the
|
|
46295
|
+
* [opened] effect) and the container simply uncovers it. Text never rewraps
|
|
46296
|
+
* mid-animation. The content is revealed from its UI side (pinned against the
|
|
46297
|
+
* UI when it comes first). Once settled open the clipping is released, so a
|
|
46298
|
+
* popover or focus ring inside is not cut at the edges.
|
|
46299
|
+
*/
|
|
46300
|
+
const css$J = /* css */`
|
|
46301
|
+
.navi_expandable {
|
|
46302
|
+
position: relative;
|
|
46303
|
+
display: flex;
|
|
46304
|
+
flex-shrink: 0;
|
|
46305
|
+
flex-direction: column;
|
|
46306
|
+
|
|
46307
|
+
> .navi_expandable_ui {
|
|
46308
|
+
display: flex;
|
|
46309
|
+
flex-shrink: 0;
|
|
46310
|
+
flex-direction: row;
|
|
46311
|
+
align-items: center;
|
|
46312
|
+
gap: 0.2em;
|
|
46313
|
+
cursor: pointer;
|
|
46314
|
+
user-select: none;
|
|
46315
|
+
|
|
46316
|
+
&:focus-visible {
|
|
46317
|
+
border-radius: 4px;
|
|
46318
|
+
outline: 2px solid AccentColor;
|
|
46319
|
+
outline-offset: 1px;
|
|
46320
|
+
}
|
|
46321
|
+
|
|
46322
|
+
> .navi_expandable_marker {
|
|
46323
|
+
display: flex;
|
|
46324
|
+
flex-shrink: 0;
|
|
46325
|
+
align-items: center;
|
|
46326
|
+
}
|
|
46327
|
+
|
|
46328
|
+
> .navi_expandable_ui_label {
|
|
46329
|
+
display: flex;
|
|
46330
|
+
flex: 1;
|
|
46331
|
+
align-items: center;
|
|
46332
|
+
gap: 0.2em;
|
|
46333
|
+
}
|
|
46334
|
+
}
|
|
46335
|
+
|
|
46336
|
+
> .navi_expandable_content_container {
|
|
46337
|
+
position: relative;
|
|
46338
|
+
display: grid;
|
|
46339
|
+
grid-template-rows: 0fr;
|
|
46340
|
+
/* The clip lives here, on the moving box, because the content inside
|
|
46341
|
+
keeps its final size during the animation (see the top comment) and
|
|
46342
|
+
overflows the track on purpose. One-sided (a clip-path with the free
|
|
46343
|
+
sides pushed far out) rather than overflow: hidden: only the side
|
|
46344
|
+
being revealed hides anything, so a badge sticking out of the other
|
|
46345
|
+
sides is visible from the very first frame of the movement. */
|
|
46346
|
+
clip-path: inset(-9999px -9999px 0 -9999px);
|
|
46347
|
+
|
|
46348
|
+
/* The sizer is what lets the track actually collapse: min-height 0 on
|
|
46349
|
+
an auto-sized item zeroes its min-content contribution. The frozen
|
|
46350
|
+
content cannot play that role itself — a definite height makes the
|
|
46351
|
+
contribution definite too, and the track then never goes below it. */
|
|
46352
|
+
> .navi_expandable_content_sizer {
|
|
46353
|
+
display: grid;
|
|
46354
|
+
min-height: 0;
|
|
46355
|
+
}
|
|
46356
|
+
}
|
|
46357
|
+
&[aria-expanded="true"] > .navi_expandable_content_container {
|
|
46358
|
+
grid-template-rows: 1fr;
|
|
46359
|
+
}
|
|
46360
|
+
/* Content before the UI: revealed against the UI side — the edge touching
|
|
46361
|
+
the UI stays, the far edge is what gets uncovered. Said at BOTH levels:
|
|
46362
|
+
a transitioning fr resolves once for the container's own size and once
|
|
46363
|
+
more inside it (the row is fraction² high), so the row must be glued to
|
|
46364
|
+
the container's UI edge and the oversized frozen content to the row's —
|
|
46365
|
+
anchoring only the inner one leaves the content following the drifting
|
|
46366
|
+
fraction² row. */
|
|
46367
|
+
&[data-content-first]:not([data-layout="column"])
|
|
46368
|
+
> .navi_expandable_content_container {
|
|
46369
|
+
align-content: end;
|
|
46370
|
+
clip-path: inset(0 -9999px -9999px -9999px);
|
|
46371
|
+
|
|
46372
|
+
> .navi_expandable_content_sizer {
|
|
46373
|
+
align-content: end;
|
|
46374
|
+
}
|
|
46375
|
+
}
|
|
46376
|
+
|
|
46377
|
+
/* The parts sit side by side, sharing their height: the UI part becomes a
|
|
46378
|
+
vertical strip and the content expands horizontally, on the columns
|
|
46379
|
+
track — the rows track collapses too, so a closed expandable is only as
|
|
46380
|
+
tall as its UI (the content, unmounted or 0-wide, says nothing about
|
|
46381
|
+
the height it will bring). */
|
|
46382
|
+
&[data-layout="column"] {
|
|
46383
|
+
flex-direction: row;
|
|
46384
|
+
|
|
46385
|
+
> .navi_expandable_ui {
|
|
46386
|
+
flex-direction: column;
|
|
46387
|
+
align-items: center;
|
|
46388
|
+
|
|
46389
|
+
> .navi_expandable_ui_label {
|
|
46390
|
+
flex-direction: column;
|
|
46391
|
+
}
|
|
46392
|
+
}
|
|
46393
|
+
> .navi_expandable_content_container {
|
|
46394
|
+
grid-template-columns: 0fr;
|
|
46395
|
+
grid-template-rows: 0fr;
|
|
46396
|
+
/* Both tracks reveal: clip the far side of each (right and bottom),
|
|
46397
|
+
the UI side and the top stay free. */
|
|
46398
|
+
clip-path: inset(-9999px 0 0 -9999px);
|
|
46399
|
+
|
|
46400
|
+
> .navi_expandable_content_sizer {
|
|
46401
|
+
min-width: 0;
|
|
46402
|
+
min-height: 0;
|
|
46403
|
+
}
|
|
46404
|
+
}
|
|
46405
|
+
&[aria-expanded="true"] > .navi_expandable_content_container {
|
|
46406
|
+
grid-template-columns: 1fr;
|
|
46407
|
+
grid-template-rows: 1fr;
|
|
46408
|
+
}
|
|
46409
|
+
/* mountWhenClosed: the content is built and width-frozen while closed
|
|
46410
|
+
(see the component), so it can size the height at all times — the
|
|
46411
|
+
expandable then keeps one stable height and only the width reveals. */
|
|
46412
|
+
&[data-closed-content-sized] > .navi_expandable_content_container {
|
|
46413
|
+
grid-template-rows: none;
|
|
46414
|
+
}
|
|
46415
|
+
&[data-content-first] > .navi_expandable_content_container {
|
|
46416
|
+
justify-content: end;
|
|
46417
|
+
clip-path: inset(-9999px -9999px 0 0);
|
|
46418
|
+
|
|
46419
|
+
> .navi_expandable_content_sizer {
|
|
46420
|
+
justify-content: end;
|
|
46421
|
+
}
|
|
46422
|
+
}
|
|
46423
|
+
}
|
|
46424
|
+
|
|
46425
|
+
&[data-animation] > .navi_expandable_content_container {
|
|
46426
|
+
transition:
|
|
46427
|
+
grid-template-rows var(--navi-expandable-animation-duration, 0.3s) ease,
|
|
46428
|
+
grid-template-columns var(--navi-expandable-animation-duration, 0.3s)
|
|
46429
|
+
ease;
|
|
46430
|
+
}
|
|
46431
|
+
@media (prefers-reduced-motion: reduce) {
|
|
46432
|
+
&[data-animation] > .navi_expandable_content_container {
|
|
46433
|
+
transition: none;
|
|
46434
|
+
}
|
|
46435
|
+
}
|
|
46436
|
+
/* Settled open: stop clipping entirely, so a popover, a focus ring or a
|
|
46437
|
+
dragged element inside the content can spill out on any side. Settled
|
|
46438
|
+
closed: clip every side — the collapsed box must show nothing, a
|
|
46439
|
+
stick-out included. In between (any movement, opening or closing) the
|
|
46440
|
+
one-sided clips above apply. */
|
|
46441
|
+
&[aria-expanded="true"][data-settled] > .navi_expandable_content_container {
|
|
46442
|
+
clip-path: none;
|
|
46443
|
+
}
|
|
46444
|
+
&:not([aria-expanded="true"])[data-settled]
|
|
46445
|
+
> .navi_expandable_content_container {
|
|
46446
|
+
clip-path: inset(0 0 0 0);
|
|
46447
|
+
}
|
|
46448
|
+
&[data-content-scrolls]
|
|
46449
|
+
> .navi_expandable_content_container
|
|
46450
|
+
> .navi_expandable_content_sizer
|
|
46451
|
+
> .navi_expandable_content {
|
|
46452
|
+
max-height: var(--navi-expandable-max-content-height);
|
|
46453
|
+
overflow-y: auto;
|
|
46454
|
+
}
|
|
46455
|
+
}
|
|
46456
|
+
`;
|
|
46457
|
+
const ExpandableContext = createContext(null);
|
|
46458
|
+
const useExpandableContext = partName => {
|
|
46459
|
+
const expandableContext = useContext(ExpandableContext);
|
|
46460
|
+
if (!expandableContext) {
|
|
46461
|
+
throw new Error(`<Expandable.${partName}> must be used inside <Expandable>`);
|
|
46462
|
+
}
|
|
46463
|
+
return expandableContext;
|
|
46464
|
+
};
|
|
46465
|
+
|
|
46466
|
+
/**
|
|
46467
|
+
* @type {import("ignore:preact").FunctionComponent<{
|
|
46468
|
+
* ui?: import("ignore:preact").ComponentChildren | ((state: { open: boolean }) => import("ignore:preact").ComponentChildren),
|
|
46469
|
+
* open?: boolean,
|
|
46470
|
+
* defaultOpen?: boolean,
|
|
46471
|
+
* signal?: import("@preact/signals").Signal<boolean>,
|
|
46472
|
+
* onToggle?: (event: Event) => void,
|
|
46473
|
+
* action?: Function,
|
|
46474
|
+
* loading?: boolean,
|
|
46475
|
+
* animation?: boolean,
|
|
46476
|
+
* layout?: "row" | "column",
|
|
46477
|
+
* autoFocus?: boolean,
|
|
46478
|
+
* maxContentHeight?: string | number,
|
|
46479
|
+
* mountWhenClosed?: boolean,
|
|
46480
|
+
* unmountWhenClosed?: boolean,
|
|
46481
|
+
* arrowKeyShortcuts?: boolean,
|
|
46482
|
+
* openKeyShortcut?: string,
|
|
46483
|
+
* closeKeyShortcut?: string,
|
|
46484
|
+
* }>}
|
|
46485
|
+
* @param ui - Shorthand for the common shape: renders `<Expandable.UI>{ui}</Expandable.UI>`
|
|
46486
|
+
* above the content (children). Any markup is allowed (buttons, links,
|
|
46487
|
+
* fields inside it keep their own behavior and do not toggle the
|
|
46488
|
+
* expandable). A function receives `{ open }` to render differently per
|
|
46489
|
+
* state. For other orders/orientations, pass `<Expandable.UI>` and
|
|
46490
|
+
* `<Expandable.Content>` as children instead.
|
|
46491
|
+
* @param open - Drives the state from outside: the expandable opens/closes to
|
|
46492
|
+
* match every change of this prop, but user interaction can still toggle it
|
|
46493
|
+
* in between (same semantics as Dialog/Popover's own `open`).
|
|
46494
|
+
* @param defaultOpen - Uncontrolled, mount-only initial state.
|
|
46495
|
+
* @param signal - Two-way binding: the expandable follows the signal and
|
|
46496
|
+
* writes back into it whenever it toggles on its own. Excludes `open`.
|
|
46497
|
+
* @param onToggle - Listens the "toggle" event dispatched on the root (a
|
|
46498
|
+
* ToggleEvent with newState/oldState where supported). Fires on every actual
|
|
46499
|
+
* state change, never on mount.
|
|
46500
|
+
* @param action - Ran when the expandable opens, aborted when it closes.
|
|
46501
|
+
* Content children may then be a function `(data) => ui` or a branches
|
|
46502
|
+
* object (`{ loading, error, completed, ... }`) — see ActionRenderer.
|
|
46503
|
+
* @param loading - Shows the loading spinner on the marker regardless of
|
|
46504
|
+
* `action`'s own loading state.
|
|
46505
|
+
* @param animation - Off by default. `true` plays the reveal transition;
|
|
46506
|
+
* duration comes from `--navi-expandable-animation-duration` (0.3s).
|
|
46507
|
+
* @param layout - `"row"` (default): the parts stack, the content expands
|
|
46508
|
+
* vertically. `"column"`: the parts sit side by side sharing their height,
|
|
46509
|
+
* the content expands horizontally next to the UI part.
|
|
46510
|
+
* @param autoFocus - Off by default (the focus stays on the UI part when
|
|
46511
|
+
* opening). `true` moves the focus into the content on open — the
|
|
46512
|
+
* `[autofocus]` element if any, the first focusable otherwise. Whatever the
|
|
46513
|
+
* setting, closing while the focus is inside the content hands it back to
|
|
46514
|
+
* the UI part (it would otherwise be lost to the closed, inert content).
|
|
46515
|
+
* @param maxContentHeight - Caps the content height; taller content scrolls
|
|
46516
|
+
* inside the expandable instead of growing it.
|
|
46517
|
+
* @param mountWhenClosed - Builds the content right away instead of on first
|
|
46518
|
+
* expansion. In layout="column" it also gives the closed expandable its
|
|
46519
|
+
* content's height (the content is kept laid out at its open width), so
|
|
46520
|
+
* opening only reveals the width instead of changing the height too.
|
|
46521
|
+
* @param unmountWhenClosed - Throws the content away once the collapse
|
|
46522
|
+
* settles — after the closing animation, so it still plays on real content —
|
|
46523
|
+
* and rebuilds it from scratch on every expansion.
|
|
46524
|
+
*/
|
|
46525
|
+
const Expandable = props => {
|
|
46526
|
+
import.meta.css = [css$J, "@jsenv/navi/src/control/expandable/expandable.jsx"];
|
|
46527
|
+
const {
|
|
46528
|
+
ref,
|
|
46529
|
+
ui,
|
|
46530
|
+
open,
|
|
46531
|
+
defaultOpen,
|
|
46532
|
+
signal,
|
|
46533
|
+
action,
|
|
46534
|
+
loading,
|
|
46535
|
+
animation = false,
|
|
46536
|
+
layout,
|
|
46537
|
+
autoFocus,
|
|
46538
|
+
maxContentHeight,
|
|
46539
|
+
mountWhenClosed,
|
|
46540
|
+
unmountWhenClosed,
|
|
46541
|
+
arrowKeyShortcuts = true,
|
|
46542
|
+
openKeyShortcut = "ArrowRight",
|
|
46543
|
+
closeKeyShortcut = "ArrowLeft",
|
|
46544
|
+
children,
|
|
46545
|
+
...rest
|
|
46546
|
+
} = props;
|
|
46547
|
+
const defaultRef = useRef();
|
|
46548
|
+
const rootRef = ref || defaultRef;
|
|
46549
|
+
const uiRef = useRef();
|
|
46550
|
+
const contentContainerRef = useRef();
|
|
46551
|
+
const contentId = useId();
|
|
46552
|
+
const isColumn = layout === "column";
|
|
46553
|
+
const closedContentSized = Boolean(isColumn && mountWhenClosed);
|
|
46554
|
+
// Reading .value during render is what subscribes the expandable to it.
|
|
46555
|
+
const openRequested = signal ? signal.value : open;
|
|
46556
|
+
const [opened, setOpened] = useState(() => Boolean(openRequested === undefined ? defaultOpen : openRequested));
|
|
46557
|
+
const openedRef = useRef(opened);
|
|
46558
|
+
openedRef.current = opened;
|
|
46559
|
+
const hasAction = Boolean(action);
|
|
46560
|
+
const effectiveAction = useAction(action);
|
|
46561
|
+
const {
|
|
46562
|
+
loading: actionLoading
|
|
46563
|
+
} = useActionStatus(effectiveAction);
|
|
46564
|
+
const [contentMounted, setContentMounted] = useState(() => Boolean(mountWhenClosed) || opened);
|
|
46565
|
+
// Same exclusion as popup_content_mount.js: content that must exist while
|
|
46566
|
+
// closed cannot also be thrown away on close.
|
|
46567
|
+
const effectiveUnmountWhenClosed = unmountWhenClosed && !mountWhenClosed;
|
|
46568
|
+
|
|
46569
|
+
// Fully open and no longer moving — what allows overflow to become visible
|
|
46570
|
+
// (see the CSS) and what unmountWhenClosed waits for before emptying.
|
|
46571
|
+
const [settled, setSettled] = useState(true);
|
|
46572
|
+
|
|
46573
|
+
// Read before the close touches the DOM: flipping the content to inert can
|
|
46574
|
+
// blur what it held, so by effect time the focus to hand back to the UI part
|
|
46575
|
+
// could already be gone.
|
|
46576
|
+
const focusedBeforeCloseRef = useRef(null);
|
|
46577
|
+
// The pointer press that is about to toggle can blur the focused field
|
|
46578
|
+
// before the click ever fires — so what held the focus has to be remembered
|
|
46579
|
+
// at pointerdown time.
|
|
46580
|
+
const focusedAtPointerDownRef = useRef(null);
|
|
46581
|
+
const onUIPointerDown = () => {
|
|
46582
|
+
focusedAtPointerDownRef.current = document.activeElement;
|
|
46583
|
+
};
|
|
46584
|
+
|
|
46585
|
+
// The content keeps its final size while the track animates (see the top
|
|
46586
|
+
// comment): its animated dimension is pinned to a measured pixel value, and
|
|
46587
|
+
// released once the movement settles.
|
|
46588
|
+
const freezeContentSize = () => {
|
|
46589
|
+
const contentContainer = contentContainerRef.current;
|
|
46590
|
+
const contentElement = contentContainer ? contentContainer.firstElementChild.firstElementChild : null;
|
|
46591
|
+
if (!contentElement) {
|
|
46592
|
+
return;
|
|
46593
|
+
}
|
|
46594
|
+
const rect = contentElement.getBoundingClientRect();
|
|
46595
|
+
if (isColumn) {
|
|
46596
|
+
// Both, not just the width: a max-height-capped content otherwise
|
|
46597
|
+
// follows the collapsing rows track down instead of holding its size.
|
|
46598
|
+
contentElement.style.width = `${rect.width}px`;
|
|
46599
|
+
contentElement.style.height = `${rect.height}px`;
|
|
46600
|
+
} else {
|
|
46601
|
+
contentElement.style.height = `${rect.height}px`;
|
|
46602
|
+
}
|
|
46603
|
+
};
|
|
46604
|
+
|
|
46605
|
+
// Where the last paint left the track, measured before the toggle commits:
|
|
46606
|
+
// 0 when fully closed, partway when reopening during a collapse. Read here
|
|
46607
|
+
// rather than in the effect — a layout read after the commit would also be
|
|
46608
|
+
// the first style recalc of the open state, starting the track transition
|
|
46609
|
+
// right there; once canceled (to measure the final size), a new transition
|
|
46610
|
+
// to the same end value refuses to start and the reveal jumps.
|
|
46611
|
+
const revealStartSizeRef = useRef(null);
|
|
46612
|
+
const toggleTo = nextOpen => {
|
|
46613
|
+
nextOpen = Boolean(nextOpen);
|
|
46614
|
+
if (nextOpen === openedRef.current) {
|
|
46615
|
+
return;
|
|
46616
|
+
}
|
|
46617
|
+
openedRef.current = nextOpen;
|
|
46618
|
+
if (nextOpen) {
|
|
46619
|
+
if (animation) {
|
|
46620
|
+
const contentContainer = contentContainerRef.current;
|
|
46621
|
+
revealStartSizeRef.current = contentContainer ? contentContainer.getBoundingClientRect() : null;
|
|
46622
|
+
}
|
|
46623
|
+
setContentMounted(true);
|
|
46624
|
+
} else {
|
|
46625
|
+
const activeElement = document.activeElement;
|
|
46626
|
+
focusedBeforeCloseRef.current = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
|
|
46627
|
+
if (animation) {
|
|
46628
|
+
// Now, while the content is still fully laid out — by effect time the
|
|
46629
|
+
// track is already heading to 0 (the opening case measures in the
|
|
46630
|
+
// effect instead, where the just-mounted content exists).
|
|
46631
|
+
freezeContentSize();
|
|
46632
|
+
}
|
|
46633
|
+
}
|
|
46634
|
+
focusedAtPointerDownRef.current = null;
|
|
46635
|
+
setOpened(nextOpen);
|
|
46636
|
+
// Flipped here, before the closing/opening commit, so effects of that very
|
|
46637
|
+
// commit already see the movement as started — unmountWhenClosed must not
|
|
46638
|
+
// read a stale "settled" and empty the content under a closing animation.
|
|
46639
|
+
setSettled(!animation);
|
|
46640
|
+
if (signal) {
|
|
46641
|
+
signal.value = nextOpen;
|
|
46642
|
+
}
|
|
46643
|
+
if (hasAction) {
|
|
46644
|
+
if (nextOpen) {
|
|
46645
|
+
effectiveAction.run();
|
|
46646
|
+
} else {
|
|
46647
|
+
effectiveAction.abort();
|
|
46648
|
+
}
|
|
46649
|
+
}
|
|
46650
|
+
};
|
|
46651
|
+
const findFirstFocusableInContent = () => {
|
|
46652
|
+
const contentContainer = contentContainerRef.current;
|
|
46653
|
+
if (!contentContainer) {
|
|
46654
|
+
return null;
|
|
46655
|
+
}
|
|
46656
|
+
const autofocusElement = contentContainer.querySelector("[autofocus]");
|
|
46657
|
+
if (autofocusElement) {
|
|
46658
|
+
return autofocusElement;
|
|
46659
|
+
}
|
|
46660
|
+
return findAfter(contentContainer, elementIsFocusable, {
|
|
46661
|
+
root: contentContainer
|
|
46662
|
+
});
|
|
46663
|
+
};
|
|
46664
|
+
|
|
46665
|
+
// Follow `open`/`signal` changes after mount (the initial value is already
|
|
46666
|
+
// in the state above). A self-initiated toggle that wrote the signal lands
|
|
46667
|
+
// here too and no-ops, since the state already matches.
|
|
46668
|
+
const isFirstOpenRequestedRunRef = useRef(true);
|
|
46669
|
+
useLayoutEffect(() => {
|
|
46670
|
+
if (isFirstOpenRequestedRunRef.current) {
|
|
46671
|
+
isFirstOpenRequestedRunRef.current = false;
|
|
46672
|
+
return;
|
|
46673
|
+
}
|
|
46674
|
+
if (openRequested === undefined) {
|
|
46675
|
+
return;
|
|
46676
|
+
}
|
|
46677
|
+
toggleTo(openRequested);
|
|
46678
|
+
}, [openRequested]);
|
|
46679
|
+
|
|
46680
|
+
// A state change: tell the world (the "toggle" event), move the focus, and
|
|
46681
|
+
// set up the reveal. Skipped on mount — nothing changed, so neither the
|
|
46682
|
+
// event nor a transition exists (and a page must not have its focus stolen
|
|
46683
|
+
// by an expandable that was simply already open).
|
|
46684
|
+
const isFirstOpenedRunRef = useRef(true);
|
|
46685
|
+
useLayoutEffect(() => {
|
|
46686
|
+
if (isFirstOpenedRunRef.current) {
|
|
46687
|
+
isFirstOpenedRunRef.current = false;
|
|
46688
|
+
return undefined;
|
|
46689
|
+
}
|
|
46690
|
+
const root = rootRef.current;
|
|
46691
|
+
root.dispatchEvent(createToggleEvent(opened));
|
|
46692
|
+
if (opened) {
|
|
46693
|
+
if (autoFocus) {
|
|
46694
|
+
const firstFocusableElement = findFirstFocusableInContent();
|
|
46695
|
+
if (firstFocusableElement) {
|
|
46696
|
+
firstFocusableElement.focus();
|
|
46697
|
+
}
|
|
46698
|
+
}
|
|
46699
|
+
} else {
|
|
46700
|
+
const focusedBeforeClose = focusedBeforeCloseRef.current;
|
|
46701
|
+
focusedBeforeCloseRef.current = null;
|
|
46702
|
+
if (focusedBeforeClose && contentContainerRef.current && contentContainerRef.current.contains(focusedBeforeClose)) {
|
|
46703
|
+
uiRef.current.focus();
|
|
46704
|
+
}
|
|
46705
|
+
}
|
|
46706
|
+
if (!animation) {
|
|
46707
|
+
return undefined;
|
|
46708
|
+
}
|
|
46709
|
+
const contentContainer = contentContainerRef.current;
|
|
46710
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
46711
|
+
if (opened) {
|
|
46712
|
+
// The reveal needs the content at its final size before the track
|
|
46713
|
+
// starts moving, and the final size only exists in the open state —
|
|
46714
|
+
// the reflow trick (see instructions.md, CSS section), with transitions
|
|
46715
|
+
// suppressed BEFORE the first layout read: this effect runs pre-paint,
|
|
46716
|
+
// so any earlier read would itself be the first recalc of the open
|
|
46717
|
+
// state and would start the track transition (see revealStartSizeRef).
|
|
46718
|
+
contentContainer.style.transitionProperty = "none";
|
|
46719
|
+
const finalRect = contentElement.getBoundingClientRect();
|
|
46720
|
+
if (isColumn) {
|
|
46721
|
+
contentElement.style.width = `${finalRect.width}px`;
|
|
46722
|
+
contentElement.style.height = `${finalRect.height}px`;
|
|
46723
|
+
} else {
|
|
46724
|
+
contentElement.style.height = `${finalRect.height}px`;
|
|
46725
|
+
}
|
|
46726
|
+
// Put the tracks back where the last paint left them and let the
|
|
46727
|
+
// transition play from there. In fr — px does not interpolate with fr.
|
|
46728
|
+
const startRect = revealStartSizeRef.current;
|
|
46729
|
+
revealStartSizeRef.current = null;
|
|
46730
|
+
const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
|
|
46731
|
+
if (isColumn) {
|
|
46732
|
+
contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
|
|
46733
|
+
if (!closedContentSized) {
|
|
46734
|
+
// The height opens alongside the width (a closed column expandable
|
|
46735
|
+
// is only as tall as its UI) — unless the closed content already
|
|
46736
|
+
// sizes it, where only the width has anywhere to go.
|
|
46737
|
+
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
46738
|
+
}
|
|
46739
|
+
} else {
|
|
46740
|
+
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
46741
|
+
}
|
|
46742
|
+
// That starting frame must be genuinely rendered to transition from it,
|
|
46743
|
+
// and transitions re-enabled BEFORE the flip back to the open value —
|
|
46744
|
+
// same order as popover.jsx's own reflow trick.
|
|
46745
|
+
contentContainer.getBoundingClientRect();
|
|
46746
|
+
contentContainer.style.transitionProperty = "";
|
|
46747
|
+
contentContainer.style.gridTemplateColumns = "";
|
|
46748
|
+
contentContainer.style.gridTemplateRows = "";
|
|
46749
|
+
}
|
|
46750
|
+
// (closing froze the content in toggleTo, while it was still laid out)
|
|
46751
|
+
const cancel = whenTransitionSettles(contentContainer, () => {
|
|
46752
|
+
contentElement.style.width = "";
|
|
46753
|
+
contentElement.style.height = "";
|
|
46754
|
+
setSettled(true);
|
|
46755
|
+
});
|
|
46756
|
+
return cancel;
|
|
46757
|
+
}, [opened]);
|
|
46758
|
+
useLayoutEffect(() => {
|
|
46759
|
+
if (settled && !opened && effectiveUnmountWhenClosed) {
|
|
46760
|
+
setContentMounted(false);
|
|
46761
|
+
}
|
|
46762
|
+
}, [settled, opened, effectiveUnmountWhenClosed]);
|
|
46763
|
+
useLayoutEffect(() => {
|
|
46764
|
+
if (mountWhenClosed) {
|
|
46765
|
+
setContentMounted(true);
|
|
46766
|
+
}
|
|
46767
|
+
}, [mountWhenClosed]);
|
|
46768
|
+
|
|
46769
|
+
// closedContentSized (column + mountWhenClosed): the closed content sizes
|
|
46770
|
+
// the height (see the CSS), which is only right if it lies at its OPEN
|
|
46771
|
+
// width — at its natural closed width (a 0-wide track) it would wrap
|
|
46772
|
+
// against nothing and stack word by word. So while closed, its width is
|
|
46773
|
+
// frozen to a silently measured open width.
|
|
46774
|
+
useLayoutEffect(() => {
|
|
46775
|
+
if (!closedContentSized || opened || !settled || !contentMounted) {
|
|
46776
|
+
return;
|
|
46777
|
+
}
|
|
46778
|
+
const contentContainer = contentContainerRef.current;
|
|
46779
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
46780
|
+
contentContainer.style.transitionProperty = "none";
|
|
46781
|
+
contentContainer.style.gridTemplateColumns = "1fr";
|
|
46782
|
+
contentElement.style.width = "";
|
|
46783
|
+
const openRect = contentElement.getBoundingClientRect();
|
|
46784
|
+
contentElement.style.width = `${openRect.width}px`;
|
|
46785
|
+
contentContainer.style.gridTemplateColumns = "";
|
|
46786
|
+
// The closed frame must be committed while transitions are still off —
|
|
46787
|
+
// re-enabled in the same recalc, the 1fr-to-0fr trip back from the silent
|
|
46788
|
+
// measurement above would play as a second closing animation.
|
|
46789
|
+
contentContainer.getBoundingClientRect();
|
|
46790
|
+
contentContainer.style.transitionProperty = "";
|
|
46791
|
+
}, [closedContentSized, opened, settled, contentMounted]);
|
|
46792
|
+
|
|
46793
|
+
// Mounted already open: the content is visible, its data is due.
|
|
46794
|
+
useEffect(() => {
|
|
46795
|
+
if (openedRef.current && hasAction) {
|
|
46796
|
+
effectiveAction.run();
|
|
46797
|
+
}
|
|
46798
|
+
}, []);
|
|
46799
|
+
const onRootKeyDown = keyboardEvent => {
|
|
46800
|
+
if (!arrowKeyShortcuts) {
|
|
46801
|
+
return;
|
|
46802
|
+
}
|
|
46803
|
+
// A nested expandable (deeper, so heard first) already answered this key.
|
|
46804
|
+
if (keyboardEvent.defaultPrevented) {
|
|
46805
|
+
return;
|
|
46806
|
+
}
|
|
46807
|
+
// Leave the key to whatever native use its target has for it (moving the
|
|
46808
|
+
// caret in a field, changing a slider) — only a press with nothing else
|
|
46809
|
+
// to do drives the expandable.
|
|
46810
|
+
const defaultAction = getKeyboardEventDefaultAction(keyboardEvent);
|
|
46811
|
+
if (defaultAction && defaultAction !== "scroll") {
|
|
46812
|
+
return;
|
|
46813
|
+
}
|
|
46814
|
+
const {
|
|
46815
|
+
key
|
|
46816
|
+
} = keyboardEvent;
|
|
46817
|
+
if (key === openKeyShortcut) {
|
|
46818
|
+
if (document.activeElement !== uiRef.current) {
|
|
46819
|
+
return;
|
|
46820
|
+
}
|
|
46821
|
+
if (!openedRef.current) {
|
|
46822
|
+
keyboardEvent.preventDefault();
|
|
46823
|
+
toggleTo(true);
|
|
46824
|
+
return;
|
|
46825
|
+
}
|
|
46826
|
+
const firstFocusableElementInContent = findFirstFocusableInContent();
|
|
46827
|
+
if (!firstFocusableElementInContent) {
|
|
46828
|
+
return;
|
|
46829
|
+
}
|
|
46830
|
+
keyboardEvent.preventDefault();
|
|
46831
|
+
firstFocusableElementInContent.focus();
|
|
46832
|
+
return;
|
|
46833
|
+
}
|
|
46834
|
+
if (key === closeKeyShortcut) {
|
|
46835
|
+
if (!openedRef.current) {
|
|
46836
|
+
return;
|
|
46837
|
+
}
|
|
46838
|
+
const uiElement = uiRef.current;
|
|
46839
|
+
if (document.activeElement === uiElement) {
|
|
46840
|
+
keyboardEvent.preventDefault();
|
|
46841
|
+
toggleTo(false);
|
|
46842
|
+
} else {
|
|
46843
|
+
keyboardEvent.preventDefault();
|
|
46844
|
+
uiElement.focus();
|
|
46845
|
+
}
|
|
46846
|
+
}
|
|
46847
|
+
};
|
|
46848
|
+
const onUIClick = clickEvent => {
|
|
46849
|
+
// A navi control inside the UI part cancels the click it consumed (see
|
|
46850
|
+
// click_to_expand.js — the UI part's own aria-expanded is what it finds).
|
|
46851
|
+
if (clickEvent.defaultPrevented) {
|
|
46852
|
+
return;
|
|
46853
|
+
}
|
|
46854
|
+
const {
|
|
46855
|
+
target
|
|
46856
|
+
} = clickEvent;
|
|
46857
|
+
if (target.nodeType === 1) {
|
|
46858
|
+
const interactiveElement = target.closest(UI_INTERACTIVE_SELECTOR);
|
|
46859
|
+
if (interactiveElement && interactiveElement !== uiRef.current && uiRef.current.contains(interactiveElement)) {
|
|
46860
|
+
return;
|
|
46861
|
+
}
|
|
46862
|
+
}
|
|
46863
|
+
toggleTo(!openedRef.current);
|
|
46864
|
+
};
|
|
46865
|
+
|
|
46866
|
+
// Space/Enter on the UI part itself (role button) — a key pressed on a
|
|
46867
|
+
// control inside it belongs to that control.
|
|
46868
|
+
const onUIKeyDown = keyboardEvent => {
|
|
46869
|
+
if (keyboardEvent.defaultPrevented) {
|
|
46870
|
+
return;
|
|
46871
|
+
}
|
|
46872
|
+
if (keyboardEvent.target !== uiRef.current) {
|
|
46873
|
+
return;
|
|
46874
|
+
}
|
|
46875
|
+
const {
|
|
46876
|
+
key
|
|
46877
|
+
} = keyboardEvent;
|
|
46878
|
+
if (key === " " || key === "Enter") {
|
|
46879
|
+
keyboardEvent.preventDefault();
|
|
46880
|
+
toggleTo(!openedRef.current);
|
|
46881
|
+
}
|
|
46882
|
+
};
|
|
46883
|
+
|
|
46884
|
+
// Where the content went, so the marker can point at it while open (closed
|
|
46885
|
+
// always points right): below by default, above when the content part comes
|
|
46886
|
+
// first, beside for layout="column" (the chevron then points back toward
|
|
46887
|
+
// the UI: left).
|
|
46888
|
+
const childArray = toChildArray(children);
|
|
46889
|
+
const firstPart = childArray.find(child => child && (child.type === ExpandableUI || child.type === ExpandableContent));
|
|
46890
|
+
const hasParts = Boolean(firstPart);
|
|
46891
|
+
const contentFirst = hasParts && firstPart.type === ExpandableContent;
|
|
46892
|
+
const openDirection = isColumn ? "left" : contentFirst ? "up" : "down";
|
|
46893
|
+
const expandableContextValue = {
|
|
46894
|
+
opened,
|
|
46895
|
+
loading: loading || hasAction && actionLoading,
|
|
46896
|
+
contentMounted,
|
|
46897
|
+
hasAction,
|
|
46898
|
+
effectiveAction,
|
|
46899
|
+
openDirection,
|
|
46900
|
+
toggleTo,
|
|
46901
|
+
onUIClick,
|
|
46902
|
+
onUIPointerDown,
|
|
46903
|
+
onUIKeyDown,
|
|
46904
|
+
uiRef,
|
|
46905
|
+
contentContainerRef,
|
|
46906
|
+
contentId
|
|
46907
|
+
};
|
|
46908
|
+
|
|
46909
|
+
// Explicit parts win; the `ui` prop + children is the shorthand for the
|
|
46910
|
+
// common shape (UI above, content below). Parts are cloned on every render:
|
|
46911
|
+
// reference-stable children would be bailed out of the commit, leaving
|
|
46912
|
+
// their context subscription to re-render them asynchronously — after the
|
|
46913
|
+
// [opened] effect above, which measures the content they render.
|
|
46914
|
+
const body = hasParts ? childArray.map(child => child && (child.type === ExpandableUI || child.type === ExpandableContent) ? cloneElement(child) : child) : jsxs(Fragment$1, {
|
|
46915
|
+
children: [jsx(ExpandableUI, {
|
|
46916
|
+
children: ui
|
|
46917
|
+
}), jsx(ExpandableContent, {
|
|
46918
|
+
children: children
|
|
46919
|
+
})]
|
|
46920
|
+
});
|
|
46921
|
+
return jsx(Box, {
|
|
46922
|
+
ref: rootRef,
|
|
46923
|
+
baseClassName: "navi_expandable",
|
|
46924
|
+
"aria-expanded": opened ? "true" : "false",
|
|
46925
|
+
"data-layout": isColumn ? "column" : undefined,
|
|
46926
|
+
"data-content-first": contentFirst ? "" : undefined,
|
|
46927
|
+
"data-animation": animation ? "" : undefined,
|
|
46928
|
+
"data-settled": settled ? "" : undefined,
|
|
46929
|
+
"data-content-scrolls": maxContentHeight === undefined ? undefined : "",
|
|
46930
|
+
"data-closed-content-sized": closedContentSized ? "" : undefined,
|
|
46931
|
+
...rest,
|
|
46932
|
+
// The protocol every command target answers (see commands.js): a
|
|
46933
|
+
// `--navi-toggle`/`--navi-open`/`--navi-close` lands here as a
|
|
46934
|
+
// navi_command whose implementation dispatches the request events below.
|
|
46935
|
+
onnavi_command: e => {
|
|
46936
|
+
rest.onnavi_command?.(e);
|
|
46937
|
+
onNaviCommand(e);
|
|
46938
|
+
},
|
|
46939
|
+
onnavi_request_open: e => {
|
|
46940
|
+
rest.onnavi_request_open?.(e);
|
|
46941
|
+
toggleTo(true);
|
|
46942
|
+
},
|
|
46943
|
+
onnavi_request_close: e => {
|
|
46944
|
+
rest.onnavi_request_close?.(e);
|
|
46945
|
+
toggleTo(false);
|
|
46946
|
+
},
|
|
46947
|
+
onKeyDown: e => {
|
|
46948
|
+
rest.onKeyDown?.(e);
|
|
46949
|
+
onRootKeyDown(e);
|
|
46950
|
+
},
|
|
46951
|
+
style: maxContentHeight === undefined ? rest.style : {
|
|
46952
|
+
"--navi-expandable-max-content-height": stringifyStyle$1(maxContentHeight, "maxHeight"),
|
|
46953
|
+
...rest.style
|
|
46954
|
+
},
|
|
46955
|
+
children: jsx(ExpandableContext.Provider, {
|
|
46956
|
+
value: expandableContextValue,
|
|
46957
|
+
children: body
|
|
46958
|
+
})
|
|
46959
|
+
});
|
|
46960
|
+
};
|
|
46961
|
+
|
|
46962
|
+
/**
|
|
46963
|
+
* The always-visible part that reveals the content: the focusable toggle
|
|
46964
|
+
* itself (role button — click, Space/Enter, arrow keys), holding the marker
|
|
46965
|
+
* plus whatever it is given — any markup, a function of `{ open }` included.
|
|
46966
|
+
* Controls inside it keep their own behavior and do not toggle. Its position
|
|
46967
|
+
* among the parts decides where the content goes (before the content: content
|
|
46968
|
+
* below/right; after it: content above/left).
|
|
46969
|
+
*
|
|
46970
|
+
* @type {import("ignore:preact").FunctionComponent<{
|
|
46971
|
+
* children?: import("ignore:preact").ComponentChildren | ((state: { open: boolean }) => import("ignore:preact").ComponentChildren),
|
|
46972
|
+
* }>}
|
|
46973
|
+
*/
|
|
46974
|
+
const ExpandableUI = ({
|
|
46975
|
+
children,
|
|
46976
|
+
...rest
|
|
46977
|
+
}) => {
|
|
46978
|
+
const {
|
|
46979
|
+
opened,
|
|
46980
|
+
loading,
|
|
46981
|
+
openDirection,
|
|
46982
|
+
toggleTo,
|
|
46983
|
+
onUIClick,
|
|
46984
|
+
onUIPointerDown,
|
|
46985
|
+
onUIKeyDown,
|
|
46986
|
+
uiRef,
|
|
46987
|
+
contentId
|
|
46988
|
+
} = useExpandableContext("UI");
|
|
46989
|
+
return jsxs("div", {
|
|
46990
|
+
ref: uiRef,
|
|
46991
|
+
className: "navi_expandable_ui",
|
|
46992
|
+
role: "button",
|
|
46993
|
+
tabIndex: 0,
|
|
46994
|
+
"aria-expanded": opened,
|
|
46995
|
+
"aria-controls": contentId,
|
|
46996
|
+
onClick: onUIClick,
|
|
46997
|
+
onPointerDown: onUIPointerDown,
|
|
46998
|
+
onKeyDown: onUIKeyDown
|
|
46999
|
+
// A command from a control inside the UI part resolves its target to
|
|
47000
|
+
// the closest [aria-expanded] — this very element (see commands.js's
|
|
47001
|
+
// resolveClosestExpandable) — so it answers the protocol too. Spread as
|
|
47002
|
+
// an object: eslint's known-DOM-property check doesn't apply to navi's
|
|
47003
|
+
// own custom events.
|
|
47004
|
+
,
|
|
47005
|
+
|
|
47006
|
+
onnavi_command: e => {
|
|
47007
|
+
onNaviCommand(e);
|
|
47008
|
+
},
|
|
47009
|
+
onnavi_request_open: () => {
|
|
47010
|
+
toggleTo(true);
|
|
47011
|
+
},
|
|
47012
|
+
onnavi_request_close: () => {
|
|
47013
|
+
toggleTo(false);
|
|
47014
|
+
},
|
|
47015
|
+
...rest,
|
|
47016
|
+
children: [jsx("span", {
|
|
47017
|
+
className: "navi_expandable_marker",
|
|
47018
|
+
"aria-hidden": "true",
|
|
47019
|
+
children: jsx(SummaryMarker, {
|
|
47020
|
+
open: opened,
|
|
47021
|
+
loading: loading,
|
|
47022
|
+
openDirection: openDirection
|
|
47023
|
+
})
|
|
47024
|
+
}), jsx("div", {
|
|
47025
|
+
className: "navi_expandable_ui_label",
|
|
47026
|
+
children: typeof children === "function" ? children({
|
|
47027
|
+
open: opened
|
|
47028
|
+
}) : children
|
|
47029
|
+
})]
|
|
47030
|
+
});
|
|
47031
|
+
};
|
|
47032
|
+
|
|
47033
|
+
/**
|
|
47034
|
+
* The revealed part. With an `action` on the Expandable, children may be a
|
|
47035
|
+
* function `(data) => ui` or a branches object — see ActionRenderer.
|
|
47036
|
+
*
|
|
47037
|
+
* @type {import("ignore:preact").FunctionComponent<{}>}
|
|
47038
|
+
*/
|
|
47039
|
+
const ExpandableContent = ({
|
|
47040
|
+
children,
|
|
47041
|
+
...rest
|
|
47042
|
+
}) => {
|
|
47043
|
+
const {
|
|
47044
|
+
opened,
|
|
47045
|
+
contentMounted,
|
|
47046
|
+
hasAction,
|
|
47047
|
+
effectiveAction,
|
|
47048
|
+
contentContainerRef,
|
|
47049
|
+
contentId
|
|
47050
|
+
} = useExpandableContext("Content");
|
|
47051
|
+
let content = children;
|
|
47052
|
+
if (hasAction) {
|
|
47053
|
+
content = jsx(ActionRenderer, {
|
|
47054
|
+
action: effectiveAction,
|
|
47055
|
+
children: children
|
|
47056
|
+
});
|
|
47057
|
+
}
|
|
47058
|
+
return jsx("div", {
|
|
47059
|
+
ref: contentContainerRef,
|
|
47060
|
+
id: contentId,
|
|
47061
|
+
className: "navi_expandable_content_container",
|
|
47062
|
+
inert: opened ? undefined : true,
|
|
47063
|
+
...rest,
|
|
47064
|
+
children: jsx("div", {
|
|
47065
|
+
className: "navi_expandable_content_sizer",
|
|
47066
|
+
children: jsx("div", {
|
|
47067
|
+
className: "navi_expandable_content",
|
|
47068
|
+
children: contentMounted ? content : null
|
|
47069
|
+
})
|
|
47070
|
+
})
|
|
47071
|
+
});
|
|
47072
|
+
};
|
|
47073
|
+
Expandable.UI = ExpandableUI;
|
|
47074
|
+
Expandable.Content = ExpandableContent;
|
|
47075
|
+
|
|
47076
|
+
// What a click inside the UI part must not toggle: it was aimed at the
|
|
47077
|
+
// control, not at the row. The UI part itself matches [role='button'] and is
|
|
47078
|
+
// the one exception, excluded at the call site.
|
|
47079
|
+
const UI_INTERACTIVE_SELECTOR = ["a[href]", "button", "input", "select", "textarea", "label", "[role='button']", "[contenteditable='']", "[contenteditable='true']", "audio[controls]", "video[controls]"].join(", ");
|
|
47080
|
+
const createToggleEvent = open => {
|
|
47081
|
+
const newState = open ? "open" : "closed";
|
|
47082
|
+
const oldState = open ? "closed" : "open";
|
|
47083
|
+
if (typeof window.ToggleEvent === "function") {
|
|
47084
|
+
return new window.ToggleEvent("toggle", {
|
|
47085
|
+
newState,
|
|
47086
|
+
oldState
|
|
47087
|
+
});
|
|
47088
|
+
}
|
|
47089
|
+
const toggleEvent = new CustomEvent("toggle");
|
|
47090
|
+
toggleEvent.newState = newState;
|
|
47091
|
+
toggleEvent.oldState = oldState;
|
|
47092
|
+
return toggleEvent;
|
|
47093
|
+
};
|
|
47094
|
+
|
|
46084
47095
|
const DEFAULT_VALIDITY_STATE = { valid: true };
|
|
46085
47096
|
const useConstraintValidityState = (ref) => {
|
|
46086
47097
|
const checkValue = () => {
|
|
@@ -46149,7 +47160,8 @@ const ControlGroup = props => {
|
|
|
46149
47160
|
stateType: "object",
|
|
46150
47161
|
cascadeValidationToChildren: true,
|
|
46151
47162
|
aggregateChildStates: props.aggregateChildStates,
|
|
46152
|
-
distributeChildUIState: props.distributeChildUIState
|
|
47163
|
+
distributeChildUIState: props.distributeChildUIState,
|
|
47164
|
+
distributeChildStates: props.distributeChildStates
|
|
46153
47165
|
});
|
|
46154
47166
|
const {
|
|
46155
47167
|
children
|
|
@@ -46163,6 +47175,7 @@ const ControlGroup = props => {
|
|
|
46163
47175
|
,
|
|
46164
47176
|
aggregateChildStates: undefined,
|
|
46165
47177
|
distributeChildUIState: undefined,
|
|
47178
|
+
distributeChildStates: undefined,
|
|
46166
47179
|
pseudoClasses: CONTROL_GROUP_PSEUDO_CLASSES,
|
|
46167
47180
|
children: jsx(ControlgroupChildrenWrapper, {
|
|
46168
47181
|
...childrenWrapperProps,
|
|
@@ -49600,7 +50613,7 @@ const seedDefaultValueFromSignal = (props) => {
|
|
|
49600
50613
|
}
|
|
49601
50614
|
};
|
|
49602
50615
|
|
|
49603
|
-
const resolveInputProps = (props) => {
|
|
50616
|
+
const resolveInputProps = (props, { controlType = "input" } = {}) => {
|
|
49604
50617
|
// `signal` carries a bound state signal. It is left on `props` on purpose:
|
|
49605
50618
|
// `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
|
|
49606
50619
|
// follow it, and `onUIAction` (ui_state_controller.js) writes user
|
|
@@ -49617,8 +50630,20 @@ const resolveInputProps = (props) => {
|
|
|
49617
50630
|
}
|
|
49618
50631
|
}
|
|
49619
50632
|
if (props.type === undefined && signalOptions.type !== undefined) {
|
|
49620
|
-
|
|
50633
|
+
const typeFromSignal =
|
|
49621
50634
|
VALIDITY_TYPE_TO_INPUT_TYPE[signalOptions.type] ?? signalOptions.type;
|
|
50635
|
+
// What a signal says is what its value IS; what a control's `type` says
|
|
50636
|
+
// is what the control is. They usually agree — a date-typed signal wants
|
|
50637
|
+
// a date field — but a boolean one maps to a checkbox, and a picker made
|
|
50638
|
+
// into a checkbox is not a picker with a different look: it is another
|
|
50639
|
+
// control, with no popup to open. A picker asked to hold a yes/no keeps
|
|
50640
|
+
// its two rows and stays itself.
|
|
50641
|
+
const wouldChangeWhatTheControlIs =
|
|
50642
|
+
controlType === "picker" &&
|
|
50643
|
+
(typeFromSignal === "checkbox" || typeFromSignal === "radio");
|
|
50644
|
+
if (!wouldChangeWhatTheControlIs) {
|
|
50645
|
+
props.type = typeFromSignal;
|
|
50646
|
+
}
|
|
49622
50647
|
}
|
|
49623
50648
|
}
|
|
49624
50649
|
|
|
@@ -54444,6 +55469,20 @@ const PickerCustomResolver = props => {
|
|
|
54444
55469
|
...props
|
|
54445
55470
|
});
|
|
54446
55471
|
}
|
|
55472
|
+
if (props.type === undefined) {
|
|
55473
|
+
// A picker with a popup of its own holds whatever the control inside it
|
|
55474
|
+
// holds — a boolean, a number, an id — and a field with no type is read
|
|
55475
|
+
// back off the DOM, where every value is a string. "false" then matches no
|
|
55476
|
+
// row, the popup empties, and that emptiness climbs back into the picker:
|
|
55477
|
+
// a value survives its own round trip only while it is text. "navi_js" is
|
|
55478
|
+
// how a field says its value is a JS one, kept beside the DOM (see
|
|
55479
|
+
// controller_registry.js) — the same thing type="array"/"object" already
|
|
55480
|
+
// say for their shapes.
|
|
55481
|
+
return jsx(PickerCustom, {
|
|
55482
|
+
...props,
|
|
55483
|
+
type: "navi_js"
|
|
55484
|
+
});
|
|
55485
|
+
}
|
|
54447
55486
|
return jsx(PickerCustom, {
|
|
54448
55487
|
...props
|
|
54449
55488
|
});
|
|
@@ -61455,7 +62494,7 @@ const PickerObjectUI = () => {
|
|
|
61455
62494
|
value,
|
|
61456
62495
|
placeholder
|
|
61457
62496
|
} = useContext(PickerContext);
|
|
61458
|
-
if (
|
|
62497
|
+
if (uiStateHoldsNothing(value)) {
|
|
61459
62498
|
if (!placeholder) {
|
|
61460
62499
|
return null;
|
|
61461
62500
|
}
|
|
@@ -61492,7 +62531,7 @@ const PickerArrayUI = () => {
|
|
|
61492
62531
|
placeholder,
|
|
61493
62532
|
maxLines
|
|
61494
62533
|
} = useContext(PickerContext);
|
|
61495
|
-
if (
|
|
62534
|
+
if (uiStateHoldsNothing(value)) {
|
|
61496
62535
|
if (!placeholder) {
|
|
61497
62536
|
return null;
|
|
61498
62537
|
}
|
|
@@ -62524,7 +63563,9 @@ const PickerFirstResolver = props => {
|
|
|
62524
63563
|
const Next = useNextResolver();
|
|
62525
63564
|
const defaultRef = useRef(null);
|
|
62526
63565
|
props.ref = props.ref || defaultRef;
|
|
62527
|
-
resolveInputProps(props
|
|
63566
|
+
resolveInputProps(props, {
|
|
63567
|
+
controlType: "picker"
|
|
63568
|
+
});
|
|
62528
63569
|
return jsx(Next, {
|
|
62529
63570
|
...props
|
|
62530
63571
|
});
|
|
@@ -63600,7 +64641,8 @@ const SpinGroup = props => {
|
|
|
63600
64641
|
stateType: "object",
|
|
63601
64642
|
cascadeValidationToChildren: true,
|
|
63602
64643
|
aggregateChildStates: props.aggregateChildStates,
|
|
63603
|
-
distributeChildUIState: props.distributeChildUIState
|
|
64644
|
+
distributeChildUIState: props.distributeChildUIState,
|
|
64645
|
+
distributeChildStates: props.distributeChildStates
|
|
63604
64646
|
});
|
|
63605
64647
|
const {
|
|
63606
64648
|
children
|
|
@@ -63617,6 +64659,7 @@ const SpinGroup = props => {
|
|
|
63617
64659
|
// not reach the DOM as unknown attributes.
|
|
63618
64660
|
aggregateChildStates: undefined,
|
|
63619
64661
|
distributeChildUIState: undefined,
|
|
64662
|
+
distributeChildStates: undefined,
|
|
63620
64663
|
baseClassName: "navi_spin_group",
|
|
63621
64664
|
pseudoClasses: SPIN_GROUP_PSEUDO_CLASSES
|
|
63622
64665
|
// What the frame and what sits between the spins are drawn from: the
|
|
@@ -68128,7 +69171,8 @@ const WheelGroup = props => {
|
|
|
68128
69171
|
stateType: "object",
|
|
68129
69172
|
cascadeValidationToChildren: true,
|
|
68130
69173
|
aggregateChildStates: props.aggregateChildStates,
|
|
68131
|
-
distributeChildUIState: props.distributeChildUIState
|
|
69174
|
+
distributeChildUIState: props.distributeChildUIState,
|
|
69175
|
+
distributeChildStates: props.distributeChildStates
|
|
68132
69176
|
});
|
|
68133
69177
|
const {
|
|
68134
69178
|
children
|
|
@@ -68187,6 +69231,7 @@ const WheelGroup = props => {
|
|
|
68187
69231
|
zoom: undefined,
|
|
68188
69232
|
aggregateChildStates: undefined,
|
|
68189
69233
|
distributeChildUIState: undefined,
|
|
69234
|
+
distributeChildStates: undefined,
|
|
68190
69235
|
baseClassName: "navi_wheel_group",
|
|
68191
69236
|
"data-horizontal": horizontal ? "" : undefined,
|
|
68192
69237
|
style: groupStyle,
|
|
@@ -68295,6 +69340,10 @@ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
|
|
|
68295
69340
|
* @param {string} [value] The time shown, as "HH:MM".
|
|
68296
69341
|
* @param {number} [minuteStep=1] How many minutes apart the values on the
|
|
68297
69342
|
* minute wheel are — 15 for quarters of an hour.
|
|
69343
|
+
* @param {{min?: number, max?: number}|number[]} [hours] Which hours the wheel
|
|
69344
|
+
* offers: `{ min: 7, max: 21 }` for a day that starts and ends somewhere, or
|
|
69345
|
+
* the list itself. All 24 by default. Rows nobody will ever land on are rows
|
|
69346
|
+
* in the way.
|
|
68298
69347
|
* @param {boolean} [loop=true] The wheels go round: 23h then 0h, 59 minutes
|
|
68299
69348
|
* then 0. What a clock does. Say `loop={false}` for two ends one cannot turn
|
|
68300
69349
|
* past.
|
|
@@ -68311,6 +69360,7 @@ const LAST_MINUTE_OF_DAY = 23 * 60 + 59;
|
|
|
68311
69360
|
*/
|
|
68312
69361
|
const TimeWheel = ({
|
|
68313
69362
|
minuteStep = 1,
|
|
69363
|
+
hours,
|
|
68314
69364
|
loop = true,
|
|
68315
69365
|
placeholder,
|
|
68316
69366
|
separator = naviI18n("time.hour_separator"),
|
|
@@ -68329,13 +69379,15 @@ const TimeWheel = ({
|
|
|
68329
69379
|
}
|
|
68330
69380
|
return minuteList;
|
|
68331
69381
|
}, [minuteStep]);
|
|
69382
|
+
const hourList = useMemo(() => resolveHourList(hours), [hours ? hours.min : undefined, hours ? hours.max : undefined, hours]);
|
|
68332
69383
|
const {
|
|
68333
|
-
aggregateChildStates
|
|
68334
|
-
|
|
69384
|
+
aggregateChildStates,
|
|
69385
|
+
distributeChildUIState
|
|
69386
|
+
} = useAnswered(placeholder, rest, aggregateTime, distributeTime);
|
|
68335
69387
|
const placeholderParts = parseTimeParts(placeholder);
|
|
68336
69388
|
return jsxs(WheelGroup, {
|
|
68337
69389
|
aggregateChildStates: aggregateChildStates,
|
|
68338
|
-
distributeChildUIState:
|
|
69390
|
+
distributeChildUIState: distributeChildUIState,
|
|
68339
69391
|
...rest,
|
|
68340
69392
|
children: [jsx(Wheel, {
|
|
68341
69393
|
name: "hour",
|
|
@@ -68345,7 +69397,7 @@ const TimeWheel = ({
|
|
|
68345
69397
|
"aria-label": hourLabel,
|
|
68346
69398
|
defaultValue: placeholderParts ? placeholderParts.hour : undefined,
|
|
68347
69399
|
...wheelProps,
|
|
68348
|
-
children:
|
|
69400
|
+
children: hourList.map(hour => jsx(Wheel.Item, {
|
|
68349
69401
|
value: hour,
|
|
68350
69402
|
paddingX: "s",
|
|
68351
69403
|
children: padTwo(hour)
|
|
@@ -68392,6 +69444,8 @@ const TimeWheel = ({
|
|
|
68392
69444
|
* `null` for neither.
|
|
68393
69445
|
* @param {number} [minuteStep=1] How many minutes apart the values on both
|
|
68394
69446
|
* minute wheels are.
|
|
69447
|
+
* @param {{min?: number, max?: number}|number[]} [hours] Which hours both
|
|
69448
|
+
* wheels offer — see `TimeWheel`.
|
|
68395
69449
|
* @param {number} [minDuration=0] How long the span must last at least, in
|
|
68396
69450
|
* minutes. Zero by default: a span of no length is a span all the same, only
|
|
68397
69451
|
* one that goes backwards is not. It is what the bounds keep between them as
|
|
@@ -68409,6 +69463,7 @@ const TimeWheel = ({
|
|
|
68409
69463
|
*/
|
|
68410
69464
|
const TimeRangeWheel = ({
|
|
68411
69465
|
minuteStep = 1,
|
|
69466
|
+
hours,
|
|
68412
69467
|
minDuration = 0,
|
|
68413
69468
|
loop = true,
|
|
68414
69469
|
placeholder,
|
|
@@ -68427,8 +69482,9 @@ const TimeRangeWheel = ({
|
|
|
68427
69482
|
// answer too, left where the placeholder put it.
|
|
68428
69483
|
const {
|
|
68429
69484
|
answeredRef,
|
|
68430
|
-
aggregateChildStates
|
|
68431
|
-
|
|
69485
|
+
aggregateChildStates,
|
|
69486
|
+
distributeChildUIState
|
|
69487
|
+
} = useAnswered(placeholder, rest, aggregateSpan, distributeSpan);
|
|
68432
69488
|
|
|
68433
69489
|
// What the pair does while it is being turned: the bound that just moved is
|
|
68434
69490
|
// the one the user is holding, so it stays where it was put and the OTHER one
|
|
@@ -68471,6 +69527,7 @@ const TimeRangeWheel = ({
|
|
|
68471
69527
|
spacing: "s",
|
|
68472
69528
|
size: size,
|
|
68473
69529
|
aggregateChildStates: aggregateChildStates,
|
|
69530
|
+
distributeChildUIState: distributeChildUIState,
|
|
68474
69531
|
...rest,
|
|
68475
69532
|
children: jsxs(AnsweredContext.Provider, {
|
|
68476
69533
|
value: answeredRef,
|
|
@@ -68482,6 +69539,7 @@ const TimeRangeWheel = ({
|
|
|
68482
69539
|
ref: startRef,
|
|
68483
69540
|
name: "start",
|
|
68484
69541
|
minuteStep: minuteStep,
|
|
69542
|
+
hours: hours,
|
|
68485
69543
|
loop: loop,
|
|
68486
69544
|
size: size,
|
|
68487
69545
|
placeholder: placeholder ? placeholder.start : undefined,
|
|
@@ -68495,6 +69553,7 @@ const TimeRangeWheel = ({
|
|
|
68495
69553
|
ref: endRef,
|
|
68496
69554
|
name: "end",
|
|
68497
69555
|
minuteStep: minuteStep,
|
|
69556
|
+
hours: hours,
|
|
68498
69557
|
loop: loop,
|
|
68499
69558
|
size: size,
|
|
68500
69559
|
placeholder: placeholder ? placeholder.end : undefined,
|
|
@@ -68536,7 +69595,7 @@ const TimeRangeWheel = ({
|
|
|
68536
69595
|
* compare its own placeholder against.
|
|
68537
69596
|
*/
|
|
68538
69597
|
const AnsweredContext = createContext(null);
|
|
68539
|
-
const useAnswered = (placeholder, props, aggregateWhenAnswered) => {
|
|
69598
|
+
const useAnswered = (placeholder, props, aggregateWhenAnswered, distributeWhenAnswered) => {
|
|
68540
69599
|
const answeredFromPair = useContext(AnsweredContext);
|
|
68541
69600
|
const ownAnsweredRef = useRef(false);
|
|
68542
69601
|
const answeredRef = answeredFromPair || ownAnsweredRef;
|
|
@@ -68548,31 +69607,81 @@ const useAnswered = (placeholder, props, aggregateWhenAnswered) => {
|
|
|
68548
69607
|
const gates = !answeredFromPair;
|
|
68549
69608
|
const placeholderRef = useRef(placeholder);
|
|
68550
69609
|
placeholderRef.current = placeholder;
|
|
68551
|
-
const
|
|
68552
|
-
if (!
|
|
68553
|
-
|
|
68554
|
-
|
|
68555
|
-
|
|
69610
|
+
const scopeRef = useRef(null);
|
|
69611
|
+
if (!scopeRef.current) {
|
|
69612
|
+
scopeRef.current = {
|
|
69613
|
+
aggregateChildStates: children => {
|
|
69614
|
+
const aggregated = aggregateWhenAnswered(children);
|
|
69615
|
+
if (answeredRef.current) {
|
|
69616
|
+
return aggregated;
|
|
69617
|
+
}
|
|
69618
|
+
if (compareTwoJsValues(aggregated, placeholderRef.current)) {
|
|
69619
|
+
return undefined;
|
|
69620
|
+
}
|
|
69621
|
+
// It moved: from here on this is an answer, and stays one even when it
|
|
69622
|
+
// is turned back onto the placeholder — somebody chose that time.
|
|
69623
|
+
answeredRef.current = true;
|
|
68556
69624
|
return aggregated;
|
|
69625
|
+
},
|
|
69626
|
+
distributeChildUIState: (groupState, child) => {
|
|
69627
|
+
if (placeholderRef.current && holdsNothing(groupState)) {
|
|
69628
|
+
// Being told there is no value is not a finger bringing a wheel back:
|
|
69629
|
+
// it is a clear, or the app writing `undefined`. The wheels go back to
|
|
69630
|
+
// showing the placeholder, and the pair is unanswered again — without
|
|
69631
|
+
// this the wheels would empty (a wheel has no blank row) and what they
|
|
69632
|
+
// still showed would climb straight back up as an answer.
|
|
69633
|
+
answeredRef.current = false;
|
|
69634
|
+
return distributeWhenAnswered(placeholderRef.current, child);
|
|
69635
|
+
}
|
|
69636
|
+
return distributeWhenAnswered(groupState, child);
|
|
68557
69637
|
}
|
|
68558
|
-
if (compareTwoJsValues(aggregated, placeholderRef.current)) {
|
|
68559
|
-
return undefined;
|
|
68560
|
-
}
|
|
68561
|
-
// It moved: from here on this is an answer, and stays one even when it is
|
|
68562
|
-
// turned back onto the placeholder — somebody chose that time.
|
|
68563
|
-
answeredRef.current = true;
|
|
68564
|
-
return aggregated;
|
|
68565
69638
|
};
|
|
68566
69639
|
}
|
|
68567
69640
|
return {
|
|
68568
69641
|
answeredRef,
|
|
68569
|
-
aggregateChildStates: gates ?
|
|
69642
|
+
aggregateChildStates: gates ? scopeRef.current.aggregateChildStates : aggregateWhenAnswered,
|
|
69643
|
+
distributeChildUIState: gates ? scopeRef.current.distributeChildUIState : distributeWhenAnswered
|
|
68570
69644
|
};
|
|
68571
69645
|
};
|
|
69646
|
+
|
|
69647
|
+
// Nothing at all: no value, an empty shape, or a shape whose every part is
|
|
69648
|
+
// itself nothing — which is what a cleared span looks like on the way down.
|
|
69649
|
+
const holdsNothing = value => {
|
|
69650
|
+
if (value === undefined || value === null || value === "") {
|
|
69651
|
+
return true;
|
|
69652
|
+
}
|
|
69653
|
+
if (typeof value !== "object") {
|
|
69654
|
+
return false;
|
|
69655
|
+
}
|
|
69656
|
+
return Object.values(value).every(holdsNothing);
|
|
69657
|
+
};
|
|
68572
69658
|
const isAnswerGivenByProps = props => props.value !== undefined || props.defaultValue !== undefined || props.signal && props.signal.value !== undefined;
|
|
68573
|
-
|
|
69659
|
+
|
|
69660
|
+
// Which hours the wheel offers: all of them, a slice of the day, or a list
|
|
69661
|
+
// written by the caller. An app whose day ends at 21h has nothing to say about
|
|
69662
|
+
// 22h and 23h, and two rows nobody will ever land on are two rows in the way.
|
|
69663
|
+
const ALL_HOURS = Array.from({
|
|
68574
69664
|
length: HOUR_COUNT
|
|
68575
69665
|
}, (_, hour) => hour);
|
|
69666
|
+
const resolveHourList = hours => {
|
|
69667
|
+
if (hours === undefined) {
|
|
69668
|
+
return ALL_HOURS;
|
|
69669
|
+
}
|
|
69670
|
+
if (Array.isArray(hours)) {
|
|
69671
|
+
return hours;
|
|
69672
|
+
}
|
|
69673
|
+
const {
|
|
69674
|
+
min = 0,
|
|
69675
|
+
max = HOUR_COUNT - 1
|
|
69676
|
+
} = hours;
|
|
69677
|
+
const hourList = [];
|
|
69678
|
+
let hour = min;
|
|
69679
|
+
while (hour <= max) {
|
|
69680
|
+
hourList.push(hour);
|
|
69681
|
+
hour += 1;
|
|
69682
|
+
}
|
|
69683
|
+
return hourList;
|
|
69684
|
+
};
|
|
68576
69685
|
const padTwo = value => String(value).padStart(2, "0");
|
|
68577
69686
|
|
|
68578
69687
|
// The two times as one span, { start, end } — the shape a pair carries.
|
|
@@ -68586,6 +69695,14 @@ const aggregateSpan = childUIStateControllers => {
|
|
|
68586
69695
|
return span;
|
|
68587
69696
|
};
|
|
68588
69697
|
|
|
69698
|
+
// The way back for a span: each time takes its own side.
|
|
69699
|
+
const distributeSpan = (groupState, childUIStateController) => {
|
|
69700
|
+
if (!groupState) {
|
|
69701
|
+
return undefined;
|
|
69702
|
+
}
|
|
69703
|
+
return groupState[childUIStateController.name];
|
|
69704
|
+
};
|
|
69705
|
+
|
|
68589
69706
|
// The two wheels as one value, "HH:MM".
|
|
68590
69707
|
const aggregateTime = childUIStateControllers => {
|
|
68591
69708
|
let hour = "";
|
|
@@ -74645,5 +75762,5 @@ const UserSvg = () => jsx("svg", {
|
|
|
74645
75762
|
})
|
|
74646
75763
|
});
|
|
74647
75764
|
|
|
74648
|
-
export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, 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, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
|
|
75765
|
+
export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, 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, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
|
|
74649
75766
|
//# sourceMappingURL=jsenv_navi.js.map
|