@jsenv/navi 0.29.164 → 0.29.166
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 +1596 -1105
- package/dist/jsenv_navi.js.map +17 -13
- package/docs/AI_INSTRUCTIONS.md +8 -3
- package/docs/navigation.md +167 -0
- package/docs/popup_open.md +4 -1
- package/docs/route_transitions.md +8 -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, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor,
|
|
7
|
+
import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, isTouchDrivenEvent, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
|
|
8
8
|
export { chainEvent, clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
|
|
9
9
|
import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
|
|
10
10
|
import { isValidElement, h, Fragment, createContext, render, toChildArray, options, cloneElement } from "preact";
|
|
@@ -27612,6 +27612,105 @@ const releaseTransitionDestination = (owner) => {
|
|
|
27612
27612
|
publishDestination(null);
|
|
27613
27613
|
};
|
|
27614
27614
|
|
|
27615
|
+
/**
|
|
27616
|
+
* The press that lands on a control the movement turned into a picture.
|
|
27617
|
+
*
|
|
27618
|
+
* A captured element is dead in both senses: it stops being painted where it
|
|
27619
|
+
* stands, and nothing hit-tests to it — the press falls through to the nearest
|
|
27620
|
+
* ancestor still being painted, whatever the pseudo-elements are told about
|
|
27621
|
+
* pointer events. For the pages that is the whole point: both are pictures of
|
|
27622
|
+
* somewhere the reader is no longer, or not yet.
|
|
27623
|
+
*
|
|
27624
|
+
* For the DOOR it is not. A page reached from the furniture — a gear in a top
|
|
27625
|
+
* bar, a "+" in a tab bar — is opened and closed by the same control, and that
|
|
27626
|
+
* control is standing exactly where its picture is drawn: the bar the two
|
|
27627
|
+
* states share is one group the browser holds still. So a press on it during
|
|
27628
|
+
* those few hundred milliseconds is aimed at what the reader can see, and
|
|
27629
|
+
* swallowing it makes the toggle feel stuck at the very moment it is being used
|
|
27630
|
+
* as one — press to open, press again to close.
|
|
27631
|
+
*
|
|
27632
|
+
* A control therefore may ask to keep answering, and while a route transition
|
|
27633
|
+
* plays the press is caught at the document and handed to it when it fell
|
|
27634
|
+
* inside its rectangle — which is where the hand thinks it pressed. The same
|
|
27635
|
+
* move route_travel.jsx makes for the box a finger reaches for mid-flight.
|
|
27636
|
+
*
|
|
27637
|
+
* ASKED FOR, never derived, and that is the whole safety of it: only the
|
|
27638
|
+
* application knows the control does not move. One that travels with the pages
|
|
27639
|
+
* has a rectangle where it WILL be, not where it is seen, and a press handed to
|
|
27640
|
+
* it fires something the reader never aimed at — worse than the press being
|
|
27641
|
+
* lost. Hence the dev warning below for a control found inside the area.
|
|
27642
|
+
*
|
|
27643
|
+
* The click alone is re-aimed, not the pointer stream that leads to it: what
|
|
27644
|
+
* has to survive is the press meaning what it meant, and the press feedback is
|
|
27645
|
+
* lost anyway — the control is a picture, so nothing it paints for its own
|
|
27646
|
+
* `:active` state reaches the screen. Synthesising a pointerdown with no
|
|
27647
|
+
* pointer behind it would start gestures (a drag, a hold) that no pointerup
|
|
27648
|
+
* ever ends.
|
|
27649
|
+
*/
|
|
27650
|
+
|
|
27651
|
+
// Worn by a control that keeps answering while the pages move. Written by
|
|
27652
|
+
// <Link pressableDuringRouteTransition> / <Button pressableDuringRouteTransition>,
|
|
27653
|
+
// and by hand on anything else — this module owns the name and does the reading.
|
|
27654
|
+
const PRESSABLE_ATTRIBUTE = "data-navi-route-transition-pressable";
|
|
27655
|
+
const PRESSABLE_SELECTOR = `[${PRESSABLE_ATTRIBUTE}]`;
|
|
27656
|
+
|
|
27657
|
+
let pressOwner = null;
|
|
27658
|
+
|
|
27659
|
+
const holdTransitionPress = (owner, areaElement) => {
|
|
27660
|
+
pressOwner = owner;
|
|
27661
|
+
// Adding the same listener twice is a no-op, so a movement taking over from
|
|
27662
|
+
// another needs nothing but the new owner.
|
|
27663
|
+
document.addEventListener("click", onDocumentClick, true);
|
|
27664
|
+
};
|
|
27665
|
+
|
|
27666
|
+
const releaseTransitionPress = (owner) => {
|
|
27667
|
+
if (owner !== pressOwner) {
|
|
27668
|
+
return;
|
|
27669
|
+
}
|
|
27670
|
+
pressOwner = null;
|
|
27671
|
+
document.removeEventListener("click", onDocumentClick, true);
|
|
27672
|
+
};
|
|
27673
|
+
|
|
27674
|
+
const onDocumentClick = (clickEvent) => {
|
|
27675
|
+
// A click of our own making, or one an application dispatched by hand: both
|
|
27676
|
+
// are already aimed, and re-aiming them by coordinates that mean nothing
|
|
27677
|
+
// (0, 0 for a synthetic click) would fire whatever sits in the corner.
|
|
27678
|
+
if (!clickEvent.isTrusted) {
|
|
27679
|
+
return;
|
|
27680
|
+
}
|
|
27681
|
+
const pressables = document.querySelectorAll(PRESSABLE_SELECTOR);
|
|
27682
|
+
if (pressables.length === 0) {
|
|
27683
|
+
return;
|
|
27684
|
+
}
|
|
27685
|
+
const { target, clientX, clientY } = clickEvent;
|
|
27686
|
+
for (const pressable of pressables) {
|
|
27687
|
+
if (pressable.contains(target)) {
|
|
27688
|
+
// It got there on its own: nothing has been photographed yet, or the
|
|
27689
|
+
// control is not part of what was.
|
|
27690
|
+
return;
|
|
27691
|
+
}
|
|
27692
|
+
}
|
|
27693
|
+
for (const pressable of pressables) {
|
|
27694
|
+
const { left, right, top, bottom } = pressable.getBoundingClientRect();
|
|
27695
|
+
if (
|
|
27696
|
+
clientX < left ||
|
|
27697
|
+
clientX > right ||
|
|
27698
|
+
clientY < top ||
|
|
27699
|
+
clientY > bottom
|
|
27700
|
+
) {
|
|
27701
|
+
continue;
|
|
27702
|
+
}
|
|
27703
|
+
// The document is not where this press was going. Stopped here so the
|
|
27704
|
+
// application sees ONE click, on the control it was aimed at, rather than
|
|
27705
|
+
// two: this one falling through and the one below.
|
|
27706
|
+
clickEvent.stopPropagation();
|
|
27707
|
+
pressable.dispatchEvent(
|
|
27708
|
+
new clickEvent.constructor(clickEvent.type, clickEvent),
|
|
27709
|
+
);
|
|
27710
|
+
return;
|
|
27711
|
+
}
|
|
27712
|
+
};
|
|
27713
|
+
|
|
27615
27714
|
/**
|
|
27616
27715
|
* The furniture around the pages — the fixed bars, and the popups standing
|
|
27617
27716
|
* over them in the top layer — photographed with them for the length of a
|
|
@@ -28039,6 +28138,178 @@ const bandTheCutMayBeRelaxedTo = (bandBefore, bandAfter, rectAfter) => {
|
|
|
28039
28138
|
};
|
|
28040
28139
|
};
|
|
28041
28140
|
|
|
28141
|
+
/**
|
|
28142
|
+
* A view transition already playing, walked back to where it started.
|
|
28143
|
+
*
|
|
28144
|
+
* The way back is not a second transition. A transition photographs the state
|
|
28145
|
+
* it leaves, so one started to undo another photographs the state being undone:
|
|
28146
|
+
* the pictures then animate FROM where the reader is TO where they were, which
|
|
28147
|
+
* is a way forward to somewhere, not a return. The same pictures are run
|
|
28148
|
+
* backwards instead, and the state is put back UNDER them before they are
|
|
28149
|
+
* dropped — the two are the same thing at the instant they are swapped, so
|
|
28150
|
+
* nothing is seen changing.
|
|
28151
|
+
*
|
|
28152
|
+
* The way back is paid for in DISTANCE, not in time. A movement is eased: at
|
|
28153
|
+
* half of its TIME the pictures have covered ~80% of their distance, so one
|
|
28154
|
+
* caught "half-way" by the eye has barely begun by the clock. Rewound at -1 it
|
|
28155
|
+
* plays those few milliseconds back through the steep end of the curve — nearly
|
|
28156
|
+
* the whole visible distance collapses into two frames, and what one sees is a
|
|
28157
|
+
* snap, not a return. So the pictures are walked over how far they LOOK from
|
|
28158
|
+
* where they are going, at the movement's own pace, each animation at the rate
|
|
28159
|
+
* that gets it there in that time.
|
|
28160
|
+
*
|
|
28161
|
+
* Where they visibly stand is computed from the clock THROUGH the easing curve,
|
|
28162
|
+
* never read off the pseudo-elements: getComputedStyle on them answers with the
|
|
28163
|
+
* un-animated value — the animated one lives on the compositor, where no
|
|
28164
|
+
* reading from here reaches. It is the same trap as the playbackRate setter,
|
|
28165
|
+
* which is why every rate below is handed over with updatePlaybackRate.
|
|
28166
|
+
*/
|
|
28167
|
+
|
|
28168
|
+
const viewTransitionAnimations = () => {
|
|
28169
|
+
const animations = [];
|
|
28170
|
+
for (const animation of document.getAnimations()) {
|
|
28171
|
+
const pseudoElement = animation.effect?.pseudoElement;
|
|
28172
|
+
if (pseudoElement && pseudoElement.startsWith("::view-transition")) {
|
|
28173
|
+
animations.push(animation);
|
|
28174
|
+
}
|
|
28175
|
+
}
|
|
28176
|
+
return animations;
|
|
28177
|
+
};
|
|
28178
|
+
|
|
28179
|
+
/**
|
|
28180
|
+
* The animation carrying the movement, not the first that comes: everything a
|
|
28181
|
+
* movement takes along is animated too — a bar held where it stands, a trait
|
|
28182
|
+
* under a tab row, a popup travelling with its page — each with a pace of its
|
|
28183
|
+
* own. A time read on one of those and turned into a fraction of ANOTHER lands
|
|
28184
|
+
* anywhere, over 1 more often than not, which reads as a movement already over.
|
|
28185
|
+
* The names are tried in order, so a caller can say "the pages, or the document
|
|
28186
|
+
* when the pages are the document".
|
|
28187
|
+
*/
|
|
28188
|
+
const findLeadAnimation = (animations, leadNames) => {
|
|
28189
|
+
for (const leadName of leadNames) {
|
|
28190
|
+
const found = animations.find((candidate) =>
|
|
28191
|
+
candidate.effect?.pseudoElement?.includes(leadName),
|
|
28192
|
+
);
|
|
28193
|
+
if (found) {
|
|
28194
|
+
return found;
|
|
28195
|
+
}
|
|
28196
|
+
}
|
|
28197
|
+
return null;
|
|
28198
|
+
};
|
|
28199
|
+
|
|
28200
|
+
// How far the pictures visibly are into their movement, between 0 and 1.
|
|
28201
|
+
const visibleProgress = (animation) => {
|
|
28202
|
+
const timing = animation.effect.getComputedTiming();
|
|
28203
|
+
const duration = timing.delay + timing.activeDuration;
|
|
28204
|
+
if (!duration) {
|
|
28205
|
+
return null;
|
|
28206
|
+
}
|
|
28207
|
+
const temporal = animation.currentTime / duration;
|
|
28208
|
+
// The easing sits on the keyframes, where a CSS animation's
|
|
28209
|
+
// animation-timing-function ends up. "ease" is what these movements play;
|
|
28210
|
+
// anything else (linear under a finger) maps time to distance one for one.
|
|
28211
|
+
const easing = animation.effect.getKeyframes()[0]?.easing;
|
|
28212
|
+
const ratio =
|
|
28213
|
+
easing === "ease" ? easedProgress(temporal, CSS_EASE) : temporal;
|
|
28214
|
+
return { duration, ratio };
|
|
28215
|
+
};
|
|
28216
|
+
|
|
28217
|
+
// A frame's worth of movement is not a movement: under it the pictures are home
|
|
28218
|
+
// already, and a rate computed over it is a division by nearly nothing.
|
|
28219
|
+
const ONE_FRAME = 17;
|
|
28220
|
+
|
|
28221
|
+
// How long the walk should take, and 0 where the movement cannot say — a lead
|
|
28222
|
+
// nobody named, a duration of nothing. The rates below fall back to the
|
|
28223
|
+
// movement's own pace there, which is the browser's answer and the only one
|
|
28224
|
+
// left.
|
|
28225
|
+
const walkTime = (animations, leadNames, remaining) => {
|
|
28226
|
+
const lead = findLeadAnimation(animations, leadNames);
|
|
28227
|
+
const visible = lead ? visibleProgress(lead) : null;
|
|
28228
|
+
if (!visible) {
|
|
28229
|
+
return 0;
|
|
28230
|
+
}
|
|
28231
|
+
const ratio = remaining ? 1 - visible.ratio : visible.ratio;
|
|
28232
|
+
return ratio * visible.duration;
|
|
28233
|
+
};
|
|
28234
|
+
|
|
28235
|
+
/**
|
|
28236
|
+
* The pictures, sent back to the state the movement left. Returns false when
|
|
28237
|
+
* there are no pictures — a movement the browser skipped, or one whose
|
|
28238
|
+
* animations it has not created yet.
|
|
28239
|
+
*/
|
|
28240
|
+
const walkPicturesHome = (animations, leadNames) => {
|
|
28241
|
+
if (animations.length === 0) {
|
|
28242
|
+
return false;
|
|
28243
|
+
}
|
|
28244
|
+
const wallTime = walkTime(animations, leadNames, false);
|
|
28245
|
+
for (const animation of animations) {
|
|
28246
|
+
const timeCovered = animation.currentTime;
|
|
28247
|
+
const rate =
|
|
28248
|
+
wallTime > ONE_FRAME && timeCovered > 0 ? -(timeCovered / wallTime) : -1;
|
|
28249
|
+
animation.updatePlaybackRate(rate);
|
|
28250
|
+
}
|
|
28251
|
+
return true;
|
|
28252
|
+
};
|
|
28253
|
+
|
|
28254
|
+
/**
|
|
28255
|
+
* The pictures, sent on to the state the movement was going to — a way back
|
|
28256
|
+
* that is itself turned round, when the door is pressed a third time. The same
|
|
28257
|
+
* arithmetic read from the other end: what is left to cover, walked at the
|
|
28258
|
+
* movement's own pace.
|
|
28259
|
+
*/
|
|
28260
|
+
const walkPicturesOn = (animations, leadNames) => {
|
|
28261
|
+
if (animations.length === 0) {
|
|
28262
|
+
return false;
|
|
28263
|
+
}
|
|
28264
|
+
const wallTime = walkTime(animations, leadNames, true);
|
|
28265
|
+
for (const animation of animations) {
|
|
28266
|
+
const timing = animation.effect.getComputedTiming();
|
|
28267
|
+
const timeLeft =
|
|
28268
|
+
timing.delay + timing.activeDuration - animation.currentTime;
|
|
28269
|
+
const rate = wallTime > ONE_FRAME && timeLeft > 0 ? timeLeft / wallTime : 1;
|
|
28270
|
+
animation.updatePlaybackRate(rate);
|
|
28271
|
+
}
|
|
28272
|
+
return true;
|
|
28273
|
+
};
|
|
28274
|
+
|
|
28275
|
+
/**
|
|
28276
|
+
* When the pictures have arrived, whichever way they were walking. A rejection
|
|
28277
|
+
* is an outcome — animations cancelled by a transition that took this one's
|
|
28278
|
+
* place — and the caller finds nothing left to undo either way.
|
|
28279
|
+
*/
|
|
28280
|
+
const whenPicturesArrived = (animations) => {
|
|
28281
|
+
if (animations.length === 0) {
|
|
28282
|
+
// Nothing to run — a transition the browser skipped, or one that never
|
|
28283
|
+
// became ready. There is no picture to undo either, so what the caller does
|
|
28284
|
+
// next is the state alone.
|
|
28285
|
+
return Promise.resolve();
|
|
28286
|
+
}
|
|
28287
|
+
return Promise.all(
|
|
28288
|
+
animations.map((animation) => animation.finished.catch(() => {})),
|
|
28289
|
+
);
|
|
28290
|
+
};
|
|
28291
|
+
|
|
28292
|
+
// CSS `ease`, evaluated: time in, distance out. Solved numerically because the
|
|
28293
|
+
// curve is parametric — two cubics sharing a parameter, with no closed form for
|
|
28294
|
+
// one against the other. Twenty halvings put the answer well under a pixel of a
|
|
28295
|
+
// screen-wide movement.
|
|
28296
|
+
const CSS_EASE = [0.25, 0.1, 0.25, 1];
|
|
28297
|
+
const bezierAxis = (s, a, b) =>
|
|
28298
|
+
3 * (1 - s) * (1 - s) * s * a + 3 * (1 - s) * s * s * b + s * s * s;
|
|
28299
|
+
const easedProgress = (x, [x1, y1, x2, y2]) => {
|
|
28300
|
+
let low = 0;
|
|
28301
|
+
let high = 1;
|
|
28302
|
+
for (let i = 0; i < 20; i++) {
|
|
28303
|
+
const mid = (low + high) / 2;
|
|
28304
|
+
if (bezierAxis(mid, x1, x2) < x) {
|
|
28305
|
+
low = mid;
|
|
28306
|
+
} else {
|
|
28307
|
+
high = mid;
|
|
28308
|
+
}
|
|
28309
|
+
}
|
|
28310
|
+
return bezierAxis((low + high) / 2, y1, y2);
|
|
28311
|
+
};
|
|
28312
|
+
|
|
28042
28313
|
installImportMetaCssBuild(import.meta);/**
|
|
28043
28314
|
* How two routes move against each other, said one relation at a time —
|
|
28044
28315
|
* without putting them in a row, and without a box in the tree.
|
|
@@ -28110,6 +28381,17 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
28110
28381
|
* rendering_hold.js for how the picture is kept honest). A browser without
|
|
28111
28382
|
* view transitions navigates without the movement.
|
|
28112
28383
|
*
|
|
28384
|
+
* A movement can be turned round. A page whose door is in the furniture is
|
|
28385
|
+
* opened and closed by the same control, so the press that closes it often
|
|
28386
|
+
* lands while it is still opening: the navigation answering is exactly the way
|
|
28387
|
+
* back of the one on screen, and the pictures are walked backwards with the
|
|
28388
|
+
* state put under them before they are dropped (see
|
|
28389
|
+
* turnRunningTransitionRound). A second transition cannot do this — it
|
|
28390
|
+
* photographs the state being undone — and it would first skip the one playing,
|
|
28391
|
+
* which is the page snapping fully open before it closes. That press has to
|
|
28392
|
+
* arrive at all, and everything photographed is deaf to the pointer for the
|
|
28393
|
+
* length of the movement: transition_press.js is the other half.
|
|
28394
|
+
*
|
|
28113
28395
|
* However many relations are defined, there is ONE watcher: every definition
|
|
28114
28396
|
* lands in a shared registry, and the watcher is rebuilt over the whole of it
|
|
28115
28397
|
* — a navigation is a single fact about the document, and the first relation
|
|
@@ -28134,6 +28416,10 @@ const TRANSITION_TARGET_ATTRIBUTE = "data-navi-route-transition-target";
|
|
|
28134
28416
|
// for that navigation and for no other — the next one is back to the relations.
|
|
28135
28417
|
const TRANSITION_REQUEST_ATTRIBUTE = "data-navi-route-transition-request";
|
|
28136
28418
|
const AREA_NAME = "navi-route-transition";
|
|
28419
|
+
// The pictures carrying the movement, among everything else it takes along —
|
|
28420
|
+
// the pages', or the document's own when the pages ARE the document (see
|
|
28421
|
+
// findLeadAnimation).
|
|
28422
|
+
const LEAD_NAMES = [AREA_NAME, "root"];
|
|
28137
28423
|
// route_travel.jsx wears this on the root for the length of one of its
|
|
28138
28424
|
// travels (its TRAVEL_ATTRIBUTE — a comment there mirrors this one). Read by
|
|
28139
28425
|
// name rather than imported: importing route_travel.jsx would pull the whole
|
|
@@ -28735,6 +29021,12 @@ const rebuildWatcher = () => {
|
|
|
28735
29021
|
if (fromIndex === index) {
|
|
28736
29022
|
return;
|
|
28737
29023
|
}
|
|
29024
|
+
if (navigationAnimated) {
|
|
29025
|
+
// The movement on screen was turned round for this very navigation (see
|
|
29026
|
+
// turnRunningTransitionRound): it is already being answered, by the
|
|
29027
|
+
// pictures the relations would otherwise photograph over.
|
|
29028
|
+
return;
|
|
29029
|
+
}
|
|
28738
29030
|
// A page in no relation at all is a real end of the crossing, not a
|
|
28739
29031
|
// missing one: it is the "anywhere" a page reached from the furniture is
|
|
28740
29032
|
// opened over and closed back onto (see findRelation).
|
|
@@ -28761,6 +29053,7 @@ const rebuildWatcher = () => {
|
|
|
28761
29053
|
beginTransition({
|
|
28762
29054
|
page: toPage,
|
|
28763
29055
|
url: navigationUrl,
|
|
29056
|
+
fromUrl: navigationFromUrl,
|
|
28764
29057
|
// Which way it plays: what the navigation itself said first — the link
|
|
28765
29058
|
// being pressed is where the way the app is being walked is known — then
|
|
28766
29059
|
// the relation, and forward for a navigation that asked for a movement
|
|
@@ -28787,6 +29080,12 @@ const rebuildWatcher = () => {
|
|
|
28787
29080
|
let defaultTransition = null;
|
|
28788
29081
|
let navigationRequest = null;
|
|
28789
29082
|
let navigationUrl = null;
|
|
29083
|
+
// Where the document stands as the navigation begins — the other end of the
|
|
29084
|
+
// crossing, and what lets the NEXT navigation be recognised as this one's way
|
|
29085
|
+
// back. Read off the document url rather than off window.location: a traversal
|
|
29086
|
+
// on a browser without the Navigation API is announced from "popstate", where
|
|
29087
|
+
// the address has already moved and location would answer with the destination.
|
|
29088
|
+
let navigationFromUrl = null;
|
|
28790
29089
|
let navigationAnimated = false;
|
|
28791
29090
|
|
|
28792
29091
|
// The two ends of every navigation, watched from here on. The picture of the
|
|
@@ -28799,18 +29098,28 @@ observeBeforeRouting(details => {
|
|
|
28799
29098
|
navigationAnimated = false;
|
|
28800
29099
|
navigationRequest = readNavigationRequest(details);
|
|
28801
29100
|
navigationUrl = details.url;
|
|
29101
|
+
navigationFromUrl = documentUrlSignal.peek();
|
|
28802
29102
|
if (relations.length === 0 && !defaultTransition && !navigationRequest) {
|
|
28803
29103
|
return;
|
|
28804
29104
|
}
|
|
28805
29105
|
holdRenderingForRouting();
|
|
29106
|
+
// Said HERE, before the relations have their say: what is on screen may be
|
|
29107
|
+
// this navigation's own way back, and that is a fact about the two ends of
|
|
29108
|
+
// the crossing alone. A pair written "none" would silence a movement — it
|
|
29109
|
+
// must not silence the undoing of one already playing.
|
|
29110
|
+
if (turnRunningTransitionRound(navigationFromUrl, navigationUrl)) {
|
|
29111
|
+
navigationAnimated = true;
|
|
29112
|
+
}
|
|
28806
29113
|
});
|
|
28807
29114
|
observeAfterRouting(() => {
|
|
28808
29115
|
const request = navigationRequest;
|
|
28809
29116
|
const url = navigationUrl;
|
|
29117
|
+
const fromUrl = navigationFromUrl;
|
|
28810
29118
|
// Read here and dropped here: a request answers for the navigation it was
|
|
28811
29119
|
// made on, and the next one is back to the relations.
|
|
28812
29120
|
navigationRequest = null;
|
|
28813
29121
|
navigationUrl = null;
|
|
29122
|
+
navigationFromUrl = null;
|
|
28814
29123
|
if (!navigationAnimated && (request || defaultTransition)) {
|
|
28815
29124
|
const {
|
|
28816
29125
|
type,
|
|
@@ -28820,6 +29129,7 @@ observeAfterRouting(() => {
|
|
|
28820
29129
|
beginTransition({
|
|
28821
29130
|
page: null,
|
|
28822
29131
|
url,
|
|
29132
|
+
fromUrl,
|
|
28823
29133
|
// A default has no direction: nothing says which of two arbitrary
|
|
28824
29134
|
// pages is before the other, and the attribute is then worn empty —
|
|
28825
29135
|
// present for whoever keys on "one of ours is playing", silent on the
|
|
@@ -28903,6 +29213,7 @@ let currentTransition = null;
|
|
|
28903
29213
|
const beginTransition = ({
|
|
28904
29214
|
page,
|
|
28905
29215
|
url,
|
|
29216
|
+
fromUrl,
|
|
28906
29217
|
direction,
|
|
28907
29218
|
type,
|
|
28908
29219
|
duration
|
|
@@ -28917,7 +29228,15 @@ const beginTransition = ({
|
|
|
28917
29228
|
console.warn("A RouteTravel is animating this navigation; the route transition defined between these routes is skipped. Animate a pair of routes with RouteTravel or defineRouteTransition, not both.");
|
|
28918
29229
|
return;
|
|
28919
29230
|
}
|
|
28920
|
-
|
|
29231
|
+
// The two ends of the crossing, kept for the length of the movement: they are
|
|
29232
|
+
// what lets the navigation after this one be recognised as its way back (see
|
|
29233
|
+
// turnRunningTransitionRound).
|
|
29234
|
+
const transition = {
|
|
29235
|
+
fromUrl: absoluteUrl(fromUrl),
|
|
29236
|
+
url: absoluteUrl(url),
|
|
29237
|
+
walkHome: null,
|
|
29238
|
+
releaseReverting: null
|
|
29239
|
+
};
|
|
28921
29240
|
currentTransition = transition;
|
|
28922
29241
|
// Said before the picture is taken: whoever names something for a movement
|
|
28923
29242
|
// between two pages decides on it now (see transition_destination.js).
|
|
@@ -28934,6 +29253,10 @@ const beginTransition = ({
|
|
|
28934
29253
|
warnOnce("several-areas", `${areaElements.length} elements carry ${TRANSITION_AREA_ATTRIBUTE}. They all take the same view-transition-name, and a name belongs to one element at a time: the browser refuses EVERY view transition of the document while this holds. Mark the one element the pages live in.`);
|
|
28935
29254
|
}
|
|
28936
29255
|
const areaElement = areaElements.length > 0 ? areaElements[0] : null;
|
|
29256
|
+
// Everything about to be photographed goes deaf to the pointer; the controls
|
|
29257
|
+
// that asked to keep answering are heard at the document instead (see
|
|
29258
|
+
// transition_press.js).
|
|
29259
|
+
holdTransitionPress(transition);
|
|
28937
29260
|
// The area as it stands before anything moves, and the band the furniture
|
|
28938
29261
|
// around it leaves free: rendering is held, so both are still the page being
|
|
28939
29262
|
// left (see transition_window.js).
|
|
@@ -29027,6 +29350,13 @@ const beginTransition = ({
|
|
|
29027
29350
|
// ending late must not strip what a later one is wearing.
|
|
29028
29351
|
renderWait.stop();
|
|
29029
29352
|
releaseRendering();
|
|
29353
|
+
// The hold a way back took, when it is still standing: the pictures were
|
|
29354
|
+
// turned round a second time and played out, or something else took the
|
|
29355
|
+
// document over mid-walk. A hold nobody gives back freezes the page.
|
|
29356
|
+
if (transition.releaseReverting) {
|
|
29357
|
+
transition.releaseReverting();
|
|
29358
|
+
transition.releaseReverting = null;
|
|
29359
|
+
}
|
|
29030
29360
|
if (currentTransition === transition) {
|
|
29031
29361
|
currentTransition = null;
|
|
29032
29362
|
documentElement.removeAttribute(TRANSITION_ATTRIBUTE);
|
|
@@ -29035,15 +29365,99 @@ const beginTransition = ({
|
|
|
29035
29365
|
releaseTransitionWindow(transition);
|
|
29036
29366
|
releaseTransitionDestination(transition);
|
|
29037
29367
|
releaseTransitionFurniture(transition);
|
|
29368
|
+
releaseTransitionPress(transition);
|
|
29038
29369
|
if (restoreDuration) {
|
|
29039
29370
|
restoreDuration();
|
|
29040
29371
|
}
|
|
29041
29372
|
}
|
|
29042
29373
|
};
|
|
29374
|
+
transition.viewTransition = viewTransition;
|
|
29043
29375
|
viewTransition.ready.then(viewTransitionReady, ignoreSkipped$1);
|
|
29044
29376
|
viewTransition.finished.then(end, end);
|
|
29045
29377
|
};
|
|
29046
29378
|
|
|
29379
|
+
/**
|
|
29380
|
+
* The movement playing, turned round rather than replaced: this navigation is
|
|
29381
|
+
* exactly the way back of the one on screen — the door pressed again while the
|
|
29382
|
+
* page it opened is still arriving.
|
|
29383
|
+
*
|
|
29384
|
+
* A second transition cannot do this. It photographs the state it leaves, which
|
|
29385
|
+
* here is the state being undone, and animates from there to the one the reader
|
|
29386
|
+
* came from: a way forward to somewhere, not a return. It also skips the one
|
|
29387
|
+
* playing, so the page snaps fully open before it slides back off — the
|
|
29388
|
+
* teleport a toggle must never show. The same pictures are walked back instead,
|
|
29389
|
+
* and the state is put under them before they are dropped (see
|
|
29390
|
+
* view_transition_revert.js).
|
|
29391
|
+
*
|
|
29392
|
+
* Only the exact way back qualifies. Anything else — a third page, a bar entry
|
|
29393
|
+
* — is a different crossing, and gets a movement of its own.
|
|
29394
|
+
*/
|
|
29395
|
+
const turnRunningTransitionRound = (fromUrl, url) => {
|
|
29396
|
+
const running = currentTransition;
|
|
29397
|
+
if (!running || !running.viewTransition || !fromUrl || !url) {
|
|
29398
|
+
return false;
|
|
29399
|
+
}
|
|
29400
|
+
const from = absoluteUrl(fromUrl);
|
|
29401
|
+
const to = absoluteUrl(url);
|
|
29402
|
+
const isWayBack = !running.walkHome && from === running.url && to === running.fromUrl;
|
|
29403
|
+
// The door pressed a third time, while the way back is still being walked:
|
|
29404
|
+
// the pictures turn round once more, where the last press left them, rather
|
|
29405
|
+
// than a fresh movement starting over them.
|
|
29406
|
+
const isWayInAgain = running.walkHome && from === running.fromUrl && to === running.url;
|
|
29407
|
+
if (!isWayBack && !isWayInAgain) {
|
|
29408
|
+
return false;
|
|
29409
|
+
}
|
|
29410
|
+
const animations = viewTransitionAnimations();
|
|
29411
|
+
if (isWayInAgain) {
|
|
29412
|
+
// The token is dropped first: the walk it stands for is the one that must
|
|
29413
|
+
// not arrive anywhere anymore, and its promise is still pending.
|
|
29414
|
+
running.walkHome = null;
|
|
29415
|
+
walkPicturesOn(animations, LEAD_NAMES);
|
|
29416
|
+
// The pages stay held. The pictures are going to the state they were going
|
|
29417
|
+
// to all along, which is the one the DOM has held since the way back began
|
|
29418
|
+
// — what must not land under them is the render the way back queued. The
|
|
29419
|
+
// hold is given back when the movement ends (see end).
|
|
29420
|
+
return true;
|
|
29421
|
+
}
|
|
29422
|
+
if (!walkPicturesHome(animations, LEAD_NAMES)) {
|
|
29423
|
+
// The pictures do not exist yet: the movement was asked for a frame ago and
|
|
29424
|
+
// the browser has not taken them. There is nothing to turn round, and this
|
|
29425
|
+
// navigation gets a movement of its own — which skips the one that never
|
|
29426
|
+
// played, and nothing was on screen to teleport.
|
|
29427
|
+
return false;
|
|
29428
|
+
}
|
|
29429
|
+
// Which walk home this is, so the one that arrives is the one still wanted: a
|
|
29430
|
+
// walk turned round mid-way leaves a promise nobody cancelled, and it settles
|
|
29431
|
+
// when the pictures reach the far end.
|
|
29432
|
+
const walkHome = {};
|
|
29433
|
+
running.walkHome = walkHome;
|
|
29434
|
+
// The pages are held where they are until the pictures are home. The picture
|
|
29435
|
+
// of the state ARRIVING is live — it is the element being drawn, not a
|
|
29436
|
+
// photograph of it — so a page put back under it while it is still on screen
|
|
29437
|
+
// shows on BOTH sides of the movement, and the way back is watched happening
|
|
29438
|
+
// to nothing.
|
|
29439
|
+
const releaseRendering = takeoverRoutingRenderingHold();
|
|
29440
|
+
running.releaseReverting = releaseRendering;
|
|
29441
|
+
whenPicturesArrived(animations).then(() => {
|
|
29442
|
+
if (currentTransition !== running || running.walkHome !== walkHome) {
|
|
29443
|
+
// Turned round again on the way home, or replaced by another movement
|
|
29444
|
+
// altogether: whoever did that owns the pictures now.
|
|
29445
|
+
return;
|
|
29446
|
+
}
|
|
29447
|
+
running.walkHome = null;
|
|
29448
|
+
running.releaseReverting = null;
|
|
29449
|
+
// The state goes back UNDER the pictures before they are dropped, so the
|
|
29450
|
+
// two are the same thing at the instant they are swapped.
|
|
29451
|
+
releaseRendering();
|
|
29452
|
+
running.viewTransition.skipTransition();
|
|
29453
|
+
});
|
|
29454
|
+
return true;
|
|
29455
|
+
};
|
|
29456
|
+
|
|
29457
|
+
// Null rather than a url built out of nothing: two crossings that both failed
|
|
29458
|
+
// to say where they went must not look like each other's way back.
|
|
29459
|
+
const absoluteUrl = url => url ? new URL(url, window.location.href).href : null;
|
|
29460
|
+
|
|
29047
29461
|
// A transition skipped by another one starting is an outcome, not a failure.
|
|
29048
29462
|
const ignoreSkipped$1 = () => {};
|
|
29049
29463
|
|
|
@@ -29271,6 +29685,12 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
29271
29685
|
* was to where it is, on the same clock as the pages. That is why the tab row
|
|
29272
29686
|
* can stay where it is, outside this box, and still move with the finger.
|
|
29273
29687
|
*/
|
|
29688
|
+
const TRAVEL_LEAD_NAMES = ["navi-route-travel"];
|
|
29689
|
+
|
|
29690
|
+
// A browser with no view transitions of its own has no picture of the page
|
|
29691
|
+
// being left, so there is nothing to drag: the gesture is still read (it is how
|
|
29692
|
+
// one asks to change tab with a thumb), and the change happens on release, as a
|
|
29693
|
+
// press on the tab would have done.
|
|
29274
29694
|
const CAN_KEEP_PICTURE = Boolean(document.startViewTransition && !document.startViewTransition.isPolyfill);
|
|
29275
29695
|
const startViewTransition = ensureDocumentStartViewTransition();
|
|
29276
29696
|
|
|
@@ -29901,34 +30321,9 @@ const RouteTravel = ({
|
|
|
29901
30321
|
// may go on rendering, and a tab row beside the box keeps answering.
|
|
29902
30322
|
const releaseRendering = freezeRouteRender();
|
|
29903
30323
|
const animations = travelAnimations(travel);
|
|
29904
|
-
|
|
29905
|
-
// at half of its TIME the pictures have covered ~80% of their distance, so
|
|
29906
|
-
// a travel caught "half-way" by the eye has barely begun by the clock.
|
|
29907
|
-
// Rewound at -1 it plays those few milliseconds back through the steep end
|
|
29908
|
-
// of the curve — nearly the whole visible distance collapses into two
|
|
29909
|
-
// frames, and what one sees is a snap, not a return. So the pictures are
|
|
29910
|
-
// walked home over `how far they LOOK from home`, at the travel's own
|
|
29911
|
-
// pace, each animation at the rate that gets it there in that time.
|
|
29912
|
-
const wallTime = revertWalkTime(animations);
|
|
29913
|
-
for (const animation of animations) {
|
|
29914
|
-
const timeLeft = animation.currentTime;
|
|
29915
|
-
const rate = wallTime > 17 && timeLeft > 0 ? -(timeLeft / wallTime) : -1;
|
|
29916
|
-
// updatePlaybackRate, never the playbackRate setter: these animations
|
|
29917
|
-
// run on the compositor, and the setter is a non-seamless change there —
|
|
29918
|
-
// on screen the pictures jump straight to their end state while the
|
|
29919
|
-
// Animation object ticks backwards unseen. What one sees then is the
|
|
29920
|
-
// travel SNAPPING home instead of returning, and no reading of
|
|
29921
|
-
// getAnimations() will say so: only the compositor knows, and
|
|
29922
|
-
// updatePlaybackRate is how a new rate is handed to it in flight.
|
|
29923
|
-
animation.updatePlaybackRate(rate);
|
|
29924
|
-
}
|
|
30324
|
+
walkPicturesHome(animations, TRAVEL_LEAD_NAMES);
|
|
29925
30325
|
releaseHold(travel);
|
|
29926
|
-
|
|
29927
|
-
// Nothing to run backwards — a transition the browser skipped, or one
|
|
29928
|
-
// that never became ready. There is no picture to undo either, so the
|
|
29929
|
-
// way back is the state alone.
|
|
29930
|
-
Promise.resolve();
|
|
29931
|
-
backAtTheStart.then(async () => {
|
|
30326
|
+
whenPicturesArrived(animations).then(async () => {
|
|
29932
30327
|
try {
|
|
29933
30328
|
pageAskedForRef.current = travel.fromPage;
|
|
29934
30329
|
// The page that was left is put back UNDER the picture before the
|
|
@@ -30463,13 +30858,7 @@ const travelAnimations = travel => {
|
|
|
30463
30858
|
if (travel.animations) {
|
|
30464
30859
|
return travel.animations;
|
|
30465
30860
|
}
|
|
30466
|
-
const animations =
|
|
30467
|
-
for (const animation of document.getAnimations()) {
|
|
30468
|
-
const pseudoElement = animation.effect?.pseudoElement;
|
|
30469
|
-
if (pseudoElement && pseudoElement.startsWith("::view-transition")) {
|
|
30470
|
-
animations.push(animation);
|
|
30471
|
-
}
|
|
30472
|
-
}
|
|
30861
|
+
const animations = viewTransitionAnimations();
|
|
30473
30862
|
if (animations.length) {
|
|
30474
30863
|
travel.animations = animations;
|
|
30475
30864
|
}
|
|
@@ -30481,12 +30870,7 @@ const travelAnimations = travel => {
|
|
|
30481
30870
|
// reaching for it must find it where it IS.
|
|
30482
30871
|
const ratioOfTravel = travel => {
|
|
30483
30872
|
const animations = travelAnimations(travel);
|
|
30484
|
-
|
|
30485
|
-
// carries along is animated too (a trait under a tab row, the page behind),
|
|
30486
|
-
// each with a duration of its own. A time read on one of those and turned
|
|
30487
|
-
// into a fraction of ANOTHER lands anywhere — over 1 more often than not,
|
|
30488
|
-
// which reads as a travel already over and jumps the pictures to their end.
|
|
30489
|
-
const animation = animations.find(candidate => candidate.effect?.pseudoElement?.includes("navi-route-travel")) || animations[0];
|
|
30873
|
+
const animation = findLeadAnimation(animations, TRAVEL_LEAD_NAMES) || animations[0];
|
|
30490
30874
|
if (!animation) {
|
|
30491
30875
|
return travel.ratio;
|
|
30492
30876
|
}
|
|
@@ -30498,52 +30882,6 @@ const ratioOfTravel = travel => {
|
|
|
30498
30882
|
return animation.currentTime / duration;
|
|
30499
30883
|
};
|
|
30500
30884
|
|
|
30501
|
-
// CSS `ease`, evaluated: time in, distance out. Solved numerically because
|
|
30502
|
-
// the curve is parametric — two cubics sharing a parameter, with no closed
|
|
30503
|
-
// form for one against the other. Twenty halvings put the answer well under
|
|
30504
|
-
// a pixel of a screen-wide travel.
|
|
30505
|
-
const CSS_EASE = [0.25, 0.1, 0.25, 1];
|
|
30506
|
-
const bezierAxis = (s, a, b) => 3 * (1 - s) * (1 - s) * s * a + 3 * (1 - s) * s * s * b + s * s * s;
|
|
30507
|
-
const easedProgress = (x, [x1, y1, x2, y2]) => {
|
|
30508
|
-
let low = 0;
|
|
30509
|
-
let high = 1;
|
|
30510
|
-
for (let i = 0; i < 20; i++) {
|
|
30511
|
-
const mid = (low + high) / 2;
|
|
30512
|
-
if (bezierAxis(mid, x1, x2) < x) {
|
|
30513
|
-
low = mid;
|
|
30514
|
-
} else {
|
|
30515
|
-
high = mid;
|
|
30516
|
-
}
|
|
30517
|
-
}
|
|
30518
|
-
return bezierAxis((low + high) / 2, y1, y2);
|
|
30519
|
-
};
|
|
30520
|
-
|
|
30521
|
-
// How long the way back should take: the distance the pictures visibly are
|
|
30522
|
-
// from home, converted to time at the travel's own pace. The distance is
|
|
30523
|
-
// computed from the clock THROUGH the easing curve, never read off the
|
|
30524
|
-
// pseudo-elements: getComputedStyle on them answers with the un-animated
|
|
30525
|
-
// value — the animated one lives on the compositor, where no reading from
|
|
30526
|
-
// here reaches (the same trap as the playbackRate setter above).
|
|
30527
|
-
const revertWalkTime = animations => {
|
|
30528
|
-
const animation = animations.find(candidate => candidate.effect?.pseudoElement?.includes("navi-route-travel"));
|
|
30529
|
-
if (!animation) {
|
|
30530
|
-
return 0;
|
|
30531
|
-
}
|
|
30532
|
-
const timing = animation.effect.getComputedTiming();
|
|
30533
|
-
const duration = timing.delay + timing.activeDuration;
|
|
30534
|
-
if (!duration) {
|
|
30535
|
-
return 0;
|
|
30536
|
-
}
|
|
30537
|
-
const temporal = animation.currentTime / duration;
|
|
30538
|
-
// The easing sits on the keyframes, where a CSS animation's
|
|
30539
|
-
// animation-timing-function ends up. "ease" is what our travels play;
|
|
30540
|
-
// anything else (linear under a finger, see the DRAGGED attribute) maps
|
|
30541
|
-
// time to distance one for one.
|
|
30542
|
-
const easing = animation.effect.getKeyframes()[0]?.easing;
|
|
30543
|
-
const visibleRatio = easing === "ease" ? easedProgress(temporal, CSS_EASE) : temporal;
|
|
30544
|
-
return visibleRatio * duration;
|
|
30545
|
-
};
|
|
30546
|
-
|
|
30547
30885
|
// Where the two pictures stand, said as a moment in the movement they would
|
|
30548
30886
|
// have played on their own: the browser knows how they move (it is written in
|
|
30549
30887
|
// CSS), so the finger only has to say how far in. They are held still by CSS
|
|
@@ -40700,6 +41038,17 @@ Object.assign(PSEUDO_CLASSES, {
|
|
|
40700
41038
|
* (`navi_value`); defaults to `href`.
|
|
40701
41039
|
* @param {boolean} [props.current] - Forces the "current" state on (otherwise
|
|
40702
41040
|
* derived from the href/route).
|
|
41041
|
+
* @param {import("../route.js").Route|import("../route.js").Route[]} [props.currentExcept] -
|
|
41042
|
+
* Route(s) inside this link's own that are NOT it: while one of them matches,
|
|
41043
|
+
* the link is not current even though its route still is. A bar entry
|
|
41044
|
+
* standing for a whole section, and one place under that section the entry
|
|
41045
|
+
* does not stand for — settings reached from everywhere and shown over
|
|
41046
|
+
* whatever the reader was on. Nothing about the routing changes: the url is
|
|
41047
|
+
* in the section, only the link stops claiming "you are here".
|
|
41048
|
+
* @param {import("../route.js").Route|import("../route.js").Route[]} [props.currentAlso] -
|
|
41049
|
+
* Route(s) other than this link's own that are it: the link is current while
|
|
41050
|
+
* one of them matches. The other side of `currentExcept` — an entry standing
|
|
41051
|
+
* for more than the one page it opens. `currentExcept` wins over it.
|
|
40703
41052
|
* @param {"text"|"icon"|"tab"} [props.variant] - Visual variant
|
|
40704
41053
|
* (`data-variant`); `"text"`/`"icon"` drop the link color/underline,
|
|
40705
41054
|
* `"tab"` renders a tab-like affordance.
|
|
@@ -40743,6 +41092,16 @@ Object.assign(PSEUDO_CLASSES, {
|
|
|
40743
41092
|
* the pair's movement and only turns it round, which is what the rare way
|
|
40744
41093
|
* round a pair usually needs. Said nowhere else, the relations answer as
|
|
40745
41094
|
* they always do.
|
|
41095
|
+
* @param {boolean} [props.pressableDuringRouteTransition] - Keep answering
|
|
41096
|
+
* presses while a route transition plays. Everything a movement photographs
|
|
41097
|
+
* goes deaf to the pointer for its whole length — a captured element is not
|
|
41098
|
+
* painted where it stands, so nothing hit-tests to it — which is right for
|
|
41099
|
+
* the pages and wrong for the door that opened them: a control in a bar the
|
|
41100
|
+
* two states share is standing exactly where its picture is drawn, and a
|
|
41101
|
+
* press on it is aimed at what the reader can see. Say it there, and only
|
|
41102
|
+
* there: a control that TRAVELS with the pages has a rectangle where it will
|
|
41103
|
+
* be, not where it is seen, and a press handed to it fires something nobody
|
|
41104
|
+
* aimed at.
|
|
40746
41105
|
* @param {boolean} [props.replace] - Go to the destination by TAKING THE PLACE
|
|
40747
41106
|
* of the current history entry instead of stacking onto it: the link stays a
|
|
40748
41107
|
* link (an address, a middle click, the keyboard, `aria-current`), only the
|
|
@@ -40781,6 +41140,8 @@ const LinkWithRoute = ({
|
|
|
40781
41140
|
route,
|
|
40782
41141
|
routeParams,
|
|
40783
41142
|
current,
|
|
41143
|
+
currentExcept,
|
|
41144
|
+
currentAlso,
|
|
40784
41145
|
children,
|
|
40785
41146
|
...rest
|
|
40786
41147
|
}) => {
|
|
@@ -40789,7 +41150,11 @@ const LinkWithRoute = ({
|
|
|
40789
41150
|
matching
|
|
40790
41151
|
} = useRouteStatus(route);
|
|
40791
41152
|
const paramsAreMatching = route.matchesParams(routeParams);
|
|
40792
|
-
const
|
|
41153
|
+
const someExceptedRouteMatching = useSomeRouteMatching(currentExcept);
|
|
41154
|
+
const someAlsoRouteMatching = useSomeRouteMatching(currentAlso);
|
|
41155
|
+
// "Except" is a veto: a route named there is somewhere else, whatever the
|
|
41156
|
+
// rest of the reading says.
|
|
41157
|
+
const linkMatching = !someExceptedRouteMatching && (matching && paramsAreMatching || someAlsoRouteMatching);
|
|
40793
41158
|
const innerCurrent = current || linkMatching;
|
|
40794
41159
|
return jsx(Link, {
|
|
40795
41160
|
href: url,
|
|
@@ -40798,6 +41163,22 @@ const LinkWithRoute = ({
|
|
|
40798
41163
|
children: children || route.buildRelativeUrl(routeParams)
|
|
40799
41164
|
});
|
|
40800
41165
|
};
|
|
41166
|
+
// A route, or a list of them, read as one answer: does any of them match.
|
|
41167
|
+
// Reading matchingSignal during the render is what subscribes the component to
|
|
41168
|
+
// it, so every route is read even once one has answered yes — stopping early
|
|
41169
|
+
// would leave the component deaf to the ones it skipped.
|
|
41170
|
+
const useSomeRouteMatching = routes => {
|
|
41171
|
+
if (!routes) {
|
|
41172
|
+
return false;
|
|
41173
|
+
}
|
|
41174
|
+
let someMatching = false;
|
|
41175
|
+
for (const route of Array.isArray(routes) ? routes : [routes]) {
|
|
41176
|
+
if (route.matchingSignal.value) {
|
|
41177
|
+
someMatching = true;
|
|
41178
|
+
}
|
|
41179
|
+
}
|
|
41180
|
+
return someMatching;
|
|
41181
|
+
};
|
|
40801
41182
|
const LinkPlain = props => {
|
|
40802
41183
|
const defaultRef = useRef();
|
|
40803
41184
|
props.ref = props.ref || defaultRef;
|
|
@@ -40821,6 +41202,7 @@ const LinkPlain = props => {
|
|
|
40821
41202
|
revealOnInteraction = false,
|
|
40822
41203
|
hrefFallback = !anchor,
|
|
40823
41204
|
routeTransition,
|
|
41205
|
+
pressableDuringRouteTransition,
|
|
40824
41206
|
replace,
|
|
40825
41207
|
children
|
|
40826
41208
|
} = props;
|
|
@@ -40981,6 +41363,8 @@ const LinkPlain = props => {
|
|
|
40981
41363
|
revealOnInteraction: undefined,
|
|
40982
41364
|
variant: undefined,
|
|
40983
41365
|
current: undefined,
|
|
41366
|
+
currentExcept: undefined,
|
|
41367
|
+
currentAlso: undefined,
|
|
40984
41368
|
currentIndicator: undefined,
|
|
40985
41369
|
currentEffectBold: undefined,
|
|
40986
41370
|
currentEffectShadow: undefined,
|
|
@@ -40990,8 +41374,12 @@ const LinkPlain = props => {
|
|
|
40990
41374
|
endIcon: undefined,
|
|
40991
41375
|
hrefFallback: undefined,
|
|
40992
41376
|
routeTransition: undefined,
|
|
41377
|
+
pressableDuringRouteTransition: undefined,
|
|
40993
41378
|
replace: undefined,
|
|
40994
41379
|
"data-navi-route-transition-request": routeTransitionRequest,
|
|
41380
|
+
...(pressableDuringRouteTransition ? {
|
|
41381
|
+
[PRESSABLE_ATTRIBUTE]: ""
|
|
41382
|
+
} : null),
|
|
40995
41383
|
...replaceRequest,
|
|
40996
41384
|
// The control's own handlers first — the interaction gate, the caller's
|
|
40997
41385
|
// onClick/onKeyDown, the command and the action — then what only a link
|
|
@@ -41032,8 +41420,14 @@ const LinkPlain = props => {
|
|
|
41032
41420
|
commandfor: slide ? nav?.slideContainer : undefined,
|
|
41033
41421
|
"aria-controls": slide ? nav?.slideContainer : undefined,
|
|
41034
41422
|
tabIndex: slide ? props.tabIndex ?? 0 : props.tabIndex,
|
|
41035
|
-
role: slide ? "tab" : props.role
|
|
41036
|
-
|
|
41423
|
+
role: slide ? "tab" : props.role
|
|
41424
|
+
// The same reading the paint uses: what the link claims about being
|
|
41425
|
+
// where the reader is has to be one claim, said to the eye and to a
|
|
41426
|
+
// screen reader alike. A slide is not a page — that tab says it with
|
|
41427
|
+
// aria-selected below.
|
|
41428
|
+
,
|
|
41429
|
+
|
|
41430
|
+
"aria-current": !slide && innerCurrent ? "page" : undefined,
|
|
41037
41431
|
"aria-selected": slide ? innerCurrent : selectionContext ? selected : undefined,
|
|
41038
41432
|
"data-value-event": "navi_value",
|
|
41039
41433
|
onnavi_value: e => {
|
|
@@ -43900,6 +44294,673 @@ const DetailsFieldContent = ({
|
|
|
43900
44294
|
});
|
|
43901
44295
|
};
|
|
43902
44296
|
|
|
44297
|
+
// How long a popup waits before handing the focus to a field, when giving it
|
|
44298
|
+
// is what raises the on-screen keyboard.
|
|
44299
|
+
//
|
|
44300
|
+
// The focus is normally given as early as possible. But a popup places itself
|
|
44301
|
+
// against the viewport, and on a phone the keyboard takes a third of that
|
|
44302
|
+
// viewport away the moment a field receives focus — so the two landing in the
|
|
44303
|
+
// same tick means the popup is still arriving when the room under it changes,
|
|
44304
|
+
// and it re-places itself mid-entrance. Waiting lets it settle first, and the
|
|
44305
|
+
// keyboard then shrinks a box that has stopped moving.
|
|
44306
|
+
//
|
|
44307
|
+
// Long enough to outlast an entrance transition rather than merely reaching
|
|
44308
|
+
// the next frame: what has to be over is the popup MOVING, not one paint of
|
|
44309
|
+
// it.
|
|
44310
|
+
const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
|
|
44311
|
+
|
|
44312
|
+
/**
|
|
44313
|
+
* Owns open/close decision-making for a popup (Dialog or Popover): guards
|
|
44314
|
+
* against duplicate requests and notifies the popup owner's own reactions.
|
|
44315
|
+
*
|
|
44316
|
+
* `controller.openEffect` is implemented by the controlled element (Dialog or
|
|
44317
|
+
* Popover), reassigned on every render so it always closes over the latest
|
|
44318
|
+
* props (scrollCapture, anchor, etc.). It performs whatever DOM side effects
|
|
44319
|
+
* are needed to make the element actually open (`showModal()`/`showPopover()`,
|
|
44320
|
+
* focus transfer, positioning, traps...) and returns its cleanup —
|
|
44321
|
+
* the matching side effects to sync back to closed (`close()`/
|
|
44322
|
+
* `hidePopover()`, releasing traps...). That cleanup is kept private to the
|
|
44323
|
+
* controller (not exposed as a property) and invoked when the popup actually
|
|
44324
|
+
* closes, however that happens.
|
|
44325
|
+
*
|
|
44326
|
+
* Dialog/Popover also call `openController.requestClose(e, { isCancel })` for
|
|
44327
|
+
* their own internal triggers (backdrop click, Escape).
|
|
44328
|
+
*
|
|
44329
|
+
* `openHandler` is the popup owner's own business logic, passed once to
|
|
44330
|
+
* `createOpenController`. Its return value is `{ onRequestClose, onClose }`,
|
|
44331
|
+
* in the spirit of CloseWatcher
|
|
44332
|
+
* (https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher) but with
|
|
44333
|
+
* clearer naming than its cancel/close pair:
|
|
44334
|
+
* - `onRequestClose(e)`: about to close — call `e.preventDefault()` to stay
|
|
44335
|
+
* open. Validation lives here.
|
|
44336
|
+
* - `onClose(e)`: actually closing, not preventable — final reactions live here.
|
|
44337
|
+
*
|
|
44338
|
+
* The controller exposes matching action methods:
|
|
44339
|
+
* - `open()`: requests opening — calls the caller's `onOpen` (see below), then
|
|
44340
|
+
* `mountContent`/`openEffect`, then `openHandler`.
|
|
44341
|
+
* - `requestClose()`: requests closing — calls `onRequestClose` then `onClose`,
|
|
44342
|
+
* stopping after the first if denied. The popup may choose to stay open,
|
|
44343
|
+
* which is what a `false` return says (`true`: closed, or closed already).
|
|
44344
|
+
* - `close()`: closes for real — calls only `onClose`, skipping
|
|
44345
|
+
* `onRequestClose` entirely. Used when there really is no choice (e.g. the
|
|
44346
|
+
* popup unmounting).
|
|
44347
|
+
*/
|
|
44348
|
+
const createOpenController = (
|
|
44349
|
+
openHandler,
|
|
44350
|
+
{ debugInteraction } = {},
|
|
44351
|
+
) => {
|
|
44352
|
+
let closeHandlers = null; // { onRequestClose, onClose } returned by openHandler
|
|
44353
|
+
let openEffectCleanup = null; // function returned by openEffect, undoes its DOM side effects
|
|
44354
|
+
let focusedAtClose = null; // what held the focus when the close was decided, see performClose
|
|
44355
|
+
|
|
44356
|
+
// Set true while we're waiting to see whether the click that follows a
|
|
44357
|
+
// mousedown-close will land back on whatever would reopen us — see
|
|
44358
|
+
// armSuppressNextOpenRequest below.
|
|
44359
|
+
let suppressNextOpenRequest = false;
|
|
44360
|
+
let disarmSuppressNextOpenRequest = null;
|
|
44361
|
+
|
|
44362
|
+
// When the popup closes because of a mousedown (e.g. clicking the
|
|
44363
|
+
// backdrop), the browser still dispatches the matching "click" afterward.
|
|
44364
|
+
// If that click lands back on the element that triggers open() (e.g. the
|
|
44365
|
+
// picker button), it would immediately reopen the popup. We cannot
|
|
44366
|
+
// preventDefault/stopPropagation the mousedown to stop that — the browser
|
|
44367
|
+
// dispatches the click regardless.
|
|
44368
|
+
//
|
|
44369
|
+
// Instead: arm a capture-phase "click" listener on document. Capture fires
|
|
44370
|
+
// before the click reaches its target, so by the time any bubble-phase
|
|
44371
|
+
// click handler (e.g. the trigger button's onClick, which calls
|
|
44372
|
+
// controller.open()) runs, `suppressNextOpenRequest` is already true and
|
|
44373
|
+
// open() ignores the request — no need to know *which* element triggers
|
|
44374
|
+
// it. A bubble-phase listener (runs after everything else, once the click
|
|
44375
|
+
// reaches document) clears the flag if nothing consumed it, meaning this
|
|
44376
|
+
// click never resulted in an open() call. A timeout is a last-resort safety
|
|
44377
|
+
// net in case the click never reaches document at all (e.g. some ancestor
|
|
44378
|
+
// called stopPropagation()) — a *task*, never a microtask: a microtask
|
|
44379
|
+
// checkpoint runs between two listeners of the same trusted event dispatch,
|
|
44380
|
+
// so it would clear the flag before the bubble-phase handler this is meant
|
|
44381
|
+
// to block ever runs, which is precisely the case it exists for.
|
|
44382
|
+
const armSuppressNextOpenRequest = () => {
|
|
44383
|
+
disarmSuppressNextOpenRequest?.();
|
|
44384
|
+
let safetyTimeout = null;
|
|
44385
|
+
const onCaptureClick = () => {
|
|
44386
|
+
document.removeEventListener("click", onCaptureClick, {
|
|
44387
|
+
capture: true,
|
|
44388
|
+
});
|
|
44389
|
+
suppressNextOpenRequest = true;
|
|
44390
|
+
document.addEventListener("click", onBubbleClick);
|
|
44391
|
+
safetyTimeout = setTimeout(() => {
|
|
44392
|
+
suppressNextOpenRequest = false;
|
|
44393
|
+
});
|
|
44394
|
+
};
|
|
44395
|
+
const onBubbleClick = () => {
|
|
44396
|
+
document.removeEventListener("click", onBubbleClick);
|
|
44397
|
+
clearTimeout(safetyTimeout);
|
|
44398
|
+
suppressNextOpenRequest = false;
|
|
44399
|
+
};
|
|
44400
|
+
disarmSuppressNextOpenRequest = () => {
|
|
44401
|
+
clearTimeout(safetyTimeout);
|
|
44402
|
+
document.removeEventListener("click", onCaptureClick, {
|
|
44403
|
+
capture: true,
|
|
44404
|
+
});
|
|
44405
|
+
document.removeEventListener("click", onBubbleClick);
|
|
44406
|
+
};
|
|
44407
|
+
document.addEventListener("click", onCaptureClick, { capture: true });
|
|
44408
|
+
};
|
|
44409
|
+
|
|
44410
|
+
const performClose = (closeEvent) => {
|
|
44411
|
+
controller.opened = false;
|
|
44412
|
+
// Read before any close effect touches the DOM: closing a native <dialog>
|
|
44413
|
+
// hands the focus back to whatever held it at showModal() time, so by the
|
|
44414
|
+
// time the close cleanup runs, the popup's content has already lost the
|
|
44415
|
+
// focus and could not be remembered for the next open.
|
|
44416
|
+
focusedAtClose = document.activeElement;
|
|
44417
|
+
|
|
44418
|
+
prevent_reopen: {
|
|
44419
|
+
const mousedownEvent = findEvent(closeEvent, "mousedown");
|
|
44420
|
+
if (mousedownEvent) {
|
|
44421
|
+
debugInteraction(
|
|
44422
|
+
closeEvent,
|
|
44423
|
+
`closed by mousedown -> ignore next click`,
|
|
44424
|
+
);
|
|
44425
|
+
armSuppressNextOpenRequest();
|
|
44426
|
+
break prevent_reopen;
|
|
44427
|
+
}
|
|
44428
|
+
|
|
44429
|
+
// The keyboard counterpart of the mousedown case above: a key press that
|
|
44430
|
+
// closes the popup and then goes on to activate the trigger, reopening it
|
|
44431
|
+
// on the spot. Space and Enter both get there, but not the same way and
|
|
44432
|
+
// not always — preventing the key unconditionally would eat presses that
|
|
44433
|
+
// were never going to activate anything (a space typed in a field, an
|
|
44434
|
+
// Enter the popup's own handler already consumed), so each is verified
|
|
44435
|
+
// before being prevented.
|
|
44436
|
+
|
|
44437
|
+
// Space: pressed on the trigger itself, which still has focus (closing
|
|
44438
|
+
// does not move it away from an element outside the popup). The browser
|
|
44439
|
+
// turns that press into a click on keyup, and that click lands back on
|
|
44440
|
+
// the trigger. Asked of the browser's own default action rather than
|
|
44441
|
+
// guessed from the tag name: a space that scrolls, or types into a field
|
|
44442
|
+
// inside the popup, has no activation to prevent and preventing it would
|
|
44443
|
+
// swallow the scroll / the character.
|
|
44444
|
+
const spaceKeyEvent = findEvent(
|
|
44445
|
+
closeEvent,
|
|
44446
|
+
(e) => e.type === "keydown" && e.key === " ",
|
|
44447
|
+
);
|
|
44448
|
+
if (
|
|
44449
|
+
spaceKeyEvent &&
|
|
44450
|
+
getKeyboardEventDefaultAction(spaceKeyEvent) === "activate"
|
|
44451
|
+
) {
|
|
44452
|
+
debugInteraction(
|
|
44453
|
+
closeEvent,
|
|
44454
|
+
`closed by space on <${spaceKeyEvent.target.tagName.toLowerCase()}> -> prevent the click it would produce (space.preventDefault())`,
|
|
44455
|
+
);
|
|
44456
|
+
// The browser won't dispatch the click, and our "space_to_open" sees
|
|
44457
|
+
// defaultPrevented too so it won't try to open the picker either.
|
|
44458
|
+
spaceKeyEvent.preventDefault();
|
|
44459
|
+
break prevent_reopen;
|
|
44460
|
+
}
|
|
44461
|
+
|
|
44462
|
+
// Enter: pressed inside the popup (its own submit button, or implicit
|
|
44463
|
+
// submission from a field it contains). The popup closes synchronously
|
|
44464
|
+
// and focus is restored to the trigger, so the activation the browser
|
|
44465
|
+
// still owes this press is delivered to the trigger instead.
|
|
44466
|
+
//
|
|
44467
|
+
// Verified on two counts: the press still owes an activation (the
|
|
44468
|
+
// browser's default action for it is one — "activate" on a submit button,
|
|
44469
|
+
// "form_submit" on a field — and nothing has consumed it yet), and it came
|
|
44470
|
+
// from inside the popup. An Enter from outside is not this case at all.
|
|
44471
|
+
const enterKeyEvent = findEvent(
|
|
44472
|
+
closeEvent,
|
|
44473
|
+
(e) => e.type === "keydown" && e.key === "Enter",
|
|
44474
|
+
);
|
|
44475
|
+
if (
|
|
44476
|
+
enterKeyEvent &&
|
|
44477
|
+
!enterKeyEvent.defaultPrevented &&
|
|
44478
|
+
ENTER_ACTIVATING_DEFAULT_ACTION_SET.has(
|
|
44479
|
+
getKeyboardEventDefaultAction(enterKeyEvent),
|
|
44480
|
+
) &&
|
|
44481
|
+
isInsideOpenPopup(enterKeyEvent.target)
|
|
44482
|
+
) {
|
|
44483
|
+
debugInteraction(
|
|
44484
|
+
closeEvent,
|
|
44485
|
+
`closed by enter from inside the popup -> prevent the activation it would deliver to the trigger (enter.preventDefault())`,
|
|
44486
|
+
);
|
|
44487
|
+
enterKeyEvent.preventDefault();
|
|
44488
|
+
break prevent_reopen;
|
|
44489
|
+
}
|
|
44490
|
+
}
|
|
44491
|
+
|
|
44492
|
+
// Sync the DOM closed first (releasing the focus trap) — only then run
|
|
44493
|
+
// the owner's own reaction (onClose may restore focus to an element
|
|
44494
|
+
// outside the popup, which the focus trap would otherwise fight while
|
|
44495
|
+
// still active).
|
|
44496
|
+
openEffectCleanup?.(closeEvent);
|
|
44497
|
+
openEffectCleanup = null;
|
|
44498
|
+
closeHandlers?.onClose?.(closeEvent);
|
|
44499
|
+
closeHandlers = null;
|
|
44500
|
+
// Last: the close effects above are what starts the exit transition the
|
|
44501
|
+
// content must outlive (see popup_content_mount.js).
|
|
44502
|
+
controller.unmountContent?.();
|
|
44503
|
+
controller.onOpenedChange?.(false, closeEvent);
|
|
44504
|
+
};
|
|
44505
|
+
const controller = {
|
|
44506
|
+
opened: false,
|
|
44507
|
+
openEffect: null,
|
|
44508
|
+
// Set by the controlled element (see popup_content_mount.js) when its
|
|
44509
|
+
// content is still waiting for a first open to be built. Called below,
|
|
44510
|
+
// before openEffect, so the popup measures and positions the real thing.
|
|
44511
|
+
mountContent: null,
|
|
44512
|
+
// The caller's own `onOpen`, set by Dialog/Popover from their props on
|
|
44513
|
+
// every render (like openEffect). Called BEFORE mountContent, so whatever
|
|
44514
|
+
// it decides — which record this dialog is opening on — is already true by
|
|
44515
|
+
// the time the content is built, positioned and shown. That order is the
|
|
44516
|
+
// whole point: learning it afterwards means the content mounted on the
|
|
44517
|
+
// previous subject first.
|
|
44518
|
+
onOpen: null,
|
|
44519
|
+
// The counterpart, set only when the popup was told to throw its content
|
|
44520
|
+
// away on close (`mount="while-opened"`). Called from performClose above.
|
|
44521
|
+
unmountContent: null,
|
|
44522
|
+
// Told whenever `opened` actually changes, whatever asked for it — an
|
|
44523
|
+
// interaction, a command, a prop — with the event that asked. What lets a
|
|
44524
|
+
// `signal` prop reflect the popup's real state, and a `navState` prop write
|
|
44525
|
+
// it into the history entry (see useOpenPropsEffectOnOpenController);
|
|
44526
|
+
// called once the open/close has fully happened rather than mid-sequence.
|
|
44527
|
+
onOpenedChange: null,
|
|
44528
|
+
open: (e, detail) => {
|
|
44529
|
+
if (controller.opened || !controller.openEffect) {
|
|
44530
|
+
return;
|
|
44531
|
+
}
|
|
44532
|
+
if (suppressNextOpenRequest) {
|
|
44533
|
+
suppressNextOpenRequest = false;
|
|
44534
|
+
return;
|
|
44535
|
+
}
|
|
44536
|
+
const requestOpenEvent = new CustomEvent("navi_request_open", {
|
|
44537
|
+
detail: { event: e, ...detail },
|
|
44538
|
+
cancelable: true,
|
|
44539
|
+
});
|
|
44540
|
+
chainEvent(requestOpenEvent, e);
|
|
44541
|
+
// we prepare focus transfer before actually opening the popover/dialog
|
|
44542
|
+
// because opnening dialog makes browser try to transfer focus (which ends up in document.body for instance)
|
|
44543
|
+
const focusTransfer = prepareFocusTransfer(
|
|
44544
|
+
requestOpenEvent,
|
|
44545
|
+
debugInteraction,
|
|
44546
|
+
);
|
|
44547
|
+
controller.transferFocusOnOpen = (el) => {
|
|
44548
|
+
// requestOpenEvent, not the raw `e` — getFocusedBeforeTransfer needs
|
|
44549
|
+
// e.detail.eventChain (built by chainEvent above) to recover the
|
|
44550
|
+
// element a mousedown/click landed on. `e` itself is usually the raw
|
|
44551
|
+
// native event: its own `.detail` is a number (click count) on a
|
|
44552
|
+
// MouseEvent, so `e.detail.eventChain` is always undefined and the
|
|
44553
|
+
// mousedown/click branches below never matched — silently falling
|
|
44554
|
+
// back to `document.activeElement`, which is often `document.body`
|
|
44555
|
+
// once mousedown.preventDefault() has kept focus from landing
|
|
44556
|
+
// anywhere yet.
|
|
44557
|
+
|
|
44558
|
+
// Two conditions, and both are about THIS opening rather than about
|
|
44559
|
+
// the device:
|
|
44560
|
+
// - the interaction: only a finger raises a virtual keyboard, and a
|
|
44561
|
+
// hybrid tablet answers "coarse" to every device-level signal
|
|
44562
|
+
// whichever of its two inputs was just used — the open event still
|
|
44563
|
+
// remembers which one it was. An opening with no pointer in it at
|
|
44564
|
+
// all (a keyboard shortcut, defaultOpen, an app calling open()) is
|
|
44565
|
+
// not one either.
|
|
44566
|
+
// - the target: focusing a button raises nothing, so there is nothing
|
|
44567
|
+
// to wait for and the focus stays immediate. Only a field the
|
|
44568
|
+
// keyboard comes up for is worth delaying — which is why the
|
|
44569
|
+
// decision is taken on the resolved target, inside transferFocus.
|
|
44570
|
+
const openedByTouch = Boolean(
|
|
44571
|
+
findEvent(requestOpenEvent, isTouchDrivenEvent),
|
|
44572
|
+
);
|
|
44573
|
+
const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
|
|
44574
|
+
getDelay: (target) =>
|
|
44575
|
+
openedByTouch && isEditableTarget(target)
|
|
44576
|
+
? FOCUS_DELAY_ON_KEYBOARD_MS
|
|
44577
|
+
: 0,
|
|
44578
|
+
});
|
|
44579
|
+
return (closeEvent) => {
|
|
44580
|
+
// Closed before the delay was up: the focus was never given, so it
|
|
44581
|
+
// must not be given now — to a field inside a popup on its way out,
|
|
44582
|
+
// raising the keyboard as it goes.
|
|
44583
|
+
cancelPendingFocus?.();
|
|
44584
|
+
markAutofocusRestoreOnClose(el, closeEvent, focusedAtClose);
|
|
44585
|
+
const focusoutEvent = findEvent(closeEvent, "focusout");
|
|
44586
|
+
if (focusoutEvent) {
|
|
44587
|
+
debugInteraction(
|
|
44588
|
+
closeEvent,
|
|
44589
|
+
`closed by focusout -> let focus go away`,
|
|
44590
|
+
);
|
|
44591
|
+
} else {
|
|
44592
|
+
const mousedownEvent = findEvent(closeEvent, "mousedown");
|
|
44593
|
+
if (mousedownEvent) {
|
|
44594
|
+
debugInteraction(
|
|
44595
|
+
closeEvent,
|
|
44596
|
+
"closed by mousedown -> prevent browser focus (mousedown.preventDefault())",
|
|
44597
|
+
);
|
|
44598
|
+
mousedownEvent.preventDefault();
|
|
44599
|
+
}
|
|
44600
|
+
focusTransfer.restoreFocus();
|
|
44601
|
+
}
|
|
44602
|
+
};
|
|
44603
|
+
};
|
|
44604
|
+
// Before mountContent, which builds the content, and before openEffect,
|
|
44605
|
+
// which shows it: what the popup opens ON has to be known before either
|
|
44606
|
+
// (see `onOpen` above).
|
|
44607
|
+
controller.onOpen?.(requestOpenEvent);
|
|
44608
|
+
// After prepareFocusTransfer, which has to record what held the focus
|
|
44609
|
+
// before anything inside the popup can claim it, and before openEffect,
|
|
44610
|
+
// which measures the popup to place it.
|
|
44611
|
+
controller.mountContent?.();
|
|
44612
|
+
// Only now — after the content has been built, before openEffect shows
|
|
44613
|
+
// it. Dialog/Popover recompute aria-expanded and navi-hidden from this
|
|
44614
|
+
// flag on every render, and mountContent above renders synchronously:
|
|
44615
|
+
// flipping it any earlier commits an already-open DOM (aria-expanded
|
|
44616
|
+
// "true", navi-hidden gone) before openEffect has run a single
|
|
44617
|
+
// statement, so the "closed" frame it pins to transition from is in
|
|
44618
|
+
// fact the open one and the entrance animation has nothing to play.
|
|
44619
|
+
// It also gives the content it just built the opening it is documented
|
|
44620
|
+
// to observe — mounted while the popup reads as closed, told it opened
|
|
44621
|
+
// right after (see popup_content_mount.js and
|
|
44622
|
+
// use_displayed_layout_effect.js).
|
|
44623
|
+
controller.opened = true;
|
|
44624
|
+
const openEffectReturnValue =
|
|
44625
|
+
controller.openEffect(requestOpenEvent) || null;
|
|
44626
|
+
openEffectCleanup = (closeEvent) => {
|
|
44627
|
+
openEffectReturnValue?.(closeEvent);
|
|
44628
|
+
};
|
|
44629
|
+
closeHandlers = openHandler(requestOpenEvent) || null;
|
|
44630
|
+
controller.onOpenedChange?.(true, requestOpenEvent);
|
|
44631
|
+
},
|
|
44632
|
+
requestClose: (
|
|
44633
|
+
e = new CustomEvent("programmatic", { detail: {} }),
|
|
44634
|
+
detail,
|
|
44635
|
+
) => {
|
|
44636
|
+
if (!controller.opened) {
|
|
44637
|
+
return true;
|
|
44638
|
+
}
|
|
44639
|
+
const requestCloseEvent = new CustomEvent("navi_request_close", {
|
|
44640
|
+
detail: { event: e, ...detail },
|
|
44641
|
+
cancelable: true,
|
|
44642
|
+
});
|
|
44643
|
+
chainEvent(requestCloseEvent, e);
|
|
44644
|
+
closeHandlers?.onRequestClose?.(requestCloseEvent);
|
|
44645
|
+
if (requestCloseEvent.defaultPrevented) {
|
|
44646
|
+
// The native <dialog> "cancel" event (Escape key) closes the dialog
|
|
44647
|
+
// by default; prevent that default so denial actually keeps it open.
|
|
44648
|
+
const nativeCancelEvent = findEvent(requestCloseEvent, "cancel");
|
|
44649
|
+
if (nativeCancelEvent) {
|
|
44650
|
+
nativeCancelEvent.preventDefault();
|
|
44651
|
+
}
|
|
44652
|
+
return false;
|
|
44653
|
+
}
|
|
44654
|
+
performClose(requestCloseEvent);
|
|
44655
|
+
return true;
|
|
44656
|
+
},
|
|
44657
|
+
close: (e = new CustomEvent("programmatic", { detail: {} }), detail) => {
|
|
44658
|
+
if (!controller.opened) {
|
|
44659
|
+
return;
|
|
44660
|
+
}
|
|
44661
|
+
const closeEvent = new CustomEvent("navi_close", {
|
|
44662
|
+
detail: { event: e, ...detail },
|
|
44663
|
+
});
|
|
44664
|
+
chainEvent(closeEvent, e);
|
|
44665
|
+
// Skips onRequestClose entirely — there is no choice here.
|
|
44666
|
+
performClose(closeEvent);
|
|
44667
|
+
},
|
|
44668
|
+
};
|
|
44669
|
+
return controller;
|
|
44670
|
+
};
|
|
44671
|
+
|
|
44672
|
+
// Inside a popup that is open right now — the popup being closed, in practice,
|
|
44673
|
+
// since that is the one the key press was delivered to.
|
|
44674
|
+
const isInsideOpenPopup = (element) => {
|
|
44675
|
+
if (!element || element.nodeType !== 1) {
|
|
44676
|
+
return false;
|
|
44677
|
+
}
|
|
44678
|
+
return Boolean(element.closest("dialog[open], [popover]:popover-open"));
|
|
44679
|
+
};
|
|
44680
|
+
|
|
44681
|
+
// What Enter is about to do when it is about to activate something: press the
|
|
44682
|
+
// focused control, or submit the form around it. Anything else it can do
|
|
44683
|
+
// (typing a newline, nothing at all) leaves no activation behind to land on the
|
|
44684
|
+
// trigger once focus is restored.
|
|
44685
|
+
const ENTER_ACTIVATING_DEFAULT_ACTION_SET = new Set([
|
|
44686
|
+
"activate",
|
|
44687
|
+
"form_submit",
|
|
44688
|
+
]);
|
|
44689
|
+
|
|
44690
|
+
// Created once per popup instance: openHandler is wrapped in a stable callback
|
|
44691
|
+
// so the controller identity never changes across renders, even though
|
|
44692
|
+
// Dialog/Popover read fresh closures (scrollTrap, etc.) via
|
|
44693
|
+
// openController.openEffect on every render.
|
|
44694
|
+
const useOpenController = (openHandler) => {
|
|
44695
|
+
const debugInteraction = useDebugInteraction();
|
|
44696
|
+
const stableOpenHandler = useStableCallback(openHandler);
|
|
44697
|
+
const controllerRef = useRef(null);
|
|
44698
|
+
if (!controllerRef.current) {
|
|
44699
|
+
controllerRef.current = createOpenController(stableOpenHandler, {
|
|
44700
|
+
debugInteraction,
|
|
44701
|
+
});
|
|
44702
|
+
}
|
|
44703
|
+
// Unmount safety net: if Dialog/Popover unmounts while still open (parent
|
|
44704
|
+
// removes it from the tree without going through requestClose()), there is
|
|
44705
|
+
// no choice to leave open — close it for real.
|
|
44706
|
+
useLayoutEffect(() => {
|
|
44707
|
+
return () => {
|
|
44708
|
+
controllerRef.current.close();
|
|
44709
|
+
};
|
|
44710
|
+
}, []);
|
|
44711
|
+
return controllerRef.current;
|
|
44712
|
+
};
|
|
44713
|
+
|
|
44714
|
+
// Nested popups that both mount already-open (`open`/`defaultOpen`) would
|
|
44715
|
+
// otherwise stack in the wrong order: Preact fires layout effects
|
|
44716
|
+
// child-first on mount, so a nested popup's own mount-open would call
|
|
44717
|
+
// showPopover() before its ancestor's — and the top layer stacks *later*
|
|
44718
|
+
// showPopover() calls above *earlier* ones (see popover.jsx's own openEffect
|
|
44719
|
+
// comment) — leaving the ancestor on top instead of the nested popup, the
|
|
44720
|
+
// opposite of what opening them one at a time (ancestor first, by real user
|
|
44721
|
+
// interaction) would produce. Batching every mount-time silent open queued
|
|
44722
|
+
// during the same commit's layout-effect phase into one microtask flush,
|
|
44723
|
+
// then simply running them in *reverse* of their registration order fixes
|
|
44724
|
+
// this — no need to compare DOM positions: since effects already fire
|
|
44725
|
+
// child-first, tree-wide, for *any* ancestor/descendant pair the descendant
|
|
44726
|
+
// is always queued before the ancestor, regardless of what else is in the
|
|
44727
|
+
// tree, so reversing the whole batch always puts every ancestor before its
|
|
44728
|
+
// own descendants. Works for any nesting depth for the same reason. Two
|
|
44729
|
+
// unrelated (sibling) popups both mounting open also get reordered
|
|
44730
|
+
// relative to each other, but there's no meaningful "correct" order between
|
|
44731
|
+
// those anyway.
|
|
44732
|
+
let pendingMountOpens = [];
|
|
44733
|
+
let mountOpenFlushScheduled = false;
|
|
44734
|
+
const scheduleMountOpen = (run) => {
|
|
44735
|
+
pendingMountOpens.push(run);
|
|
44736
|
+
if (mountOpenFlushScheduled) {
|
|
44737
|
+
return;
|
|
44738
|
+
}
|
|
44739
|
+
mountOpenFlushScheduled = true;
|
|
44740
|
+
queueMicrotask(() => {
|
|
44741
|
+
const entries = pendingMountOpens;
|
|
44742
|
+
pendingMountOpens = [];
|
|
44743
|
+
mountOpenFlushScheduled = false;
|
|
44744
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
44745
|
+
entries[i]();
|
|
44746
|
+
}
|
|
44747
|
+
});
|
|
44748
|
+
};
|
|
44749
|
+
|
|
44750
|
+
// Where the popup's open state is kept, when it is kept anywhere: `navState`
|
|
44751
|
+
// resolved to the `{ id, type }` useNavState wants.
|
|
44752
|
+
//
|
|
44753
|
+
// `true` takes the popup's own id — a popup a `--navi-open` command can name is
|
|
44754
|
+
// a popup that already has a stable one, and that id is what identifies its
|
|
44755
|
+
// open state too.
|
|
44756
|
+
const NO_NAV_STATE = { id: undefined, type: "replace" };
|
|
44757
|
+
const resolveNavStateProp = (navState, popupId, name) => {
|
|
44758
|
+
if (!navState) {
|
|
44759
|
+
return NO_NAV_STATE;
|
|
44760
|
+
}
|
|
44761
|
+
if (navState === true) {
|
|
44762
|
+
return { id: popupId, type: "replace" };
|
|
44763
|
+
}
|
|
44764
|
+
if (typeof navState === "string") {
|
|
44765
|
+
return { id: navState, type: "replace" };
|
|
44766
|
+
}
|
|
44767
|
+
return { id: navState.id || popupId, type: navState.type || "replace" };
|
|
44768
|
+
};
|
|
44769
|
+
|
|
44770
|
+
// What a write of the open state is worth in the history, when the signal
|
|
44771
|
+
// holding it is bound to a url (a route's `searchParams`, see route.js). A
|
|
44772
|
+
// plain signal has no `set` and takes the value as it always did.
|
|
44773
|
+
const writeInSignal = (signal, value, { history }) => {
|
|
44774
|
+
if (signal.set) {
|
|
44775
|
+
signal.set(value, { history });
|
|
44776
|
+
return;
|
|
44777
|
+
}
|
|
44778
|
+
signal.value = value;
|
|
44779
|
+
};
|
|
44780
|
+
|
|
44781
|
+
// The popup says where it is, into a `signal` the caller holds — and when that
|
|
44782
|
+
// signal lives in a url, saying so is a navigation. It is worth exactly what
|
|
44783
|
+
// the same move is worth when the open state lives in the history entry
|
|
44784
|
+
// instead (see useNavState's own leave()):
|
|
44785
|
+
// - the opening is worth what the state declares (`history: "push"` for a
|
|
44786
|
+
// popup one can back out of, the default replacement for one that merely
|
|
44787
|
+
// qualifies the screen one is on);
|
|
44788
|
+
// - the closing is never an entry of its own. Stacking one would leave the
|
|
44789
|
+
// entry that carries the popup BEHIND the reader, and their next back press
|
|
44790
|
+
// would walk straight back into the popup they just closed.
|
|
44791
|
+
// A cancel (Escape, the backdrop, --navi-cancel) goes back to before the
|
|
44792
|
+
// opening rather than rewriting the entry, so everything else written to the
|
|
44793
|
+
// url while the popup was open goes back with it. A close that is not a cancel
|
|
44794
|
+
// keeps those writes and only drops the popup.
|
|
44795
|
+
const writeOpenedInSignal = (signal, opened, event) => {
|
|
44796
|
+
if (signal.peek() === opened) {
|
|
44797
|
+
// The signal already says so, meaning this open/close IS what it asked
|
|
44798
|
+
// for: a back press that took the popup out of the url, the application
|
|
44799
|
+
// writing it. Nothing to write back — and nothing to go back to either,
|
|
44800
|
+
// since the navigation navBack would undo is the one that asked for this.
|
|
44801
|
+
return;
|
|
44802
|
+
}
|
|
44803
|
+
if (opened) {
|
|
44804
|
+
signal.value = true;
|
|
44805
|
+
return;
|
|
44806
|
+
}
|
|
44807
|
+
if (
|
|
44808
|
+
event?.detail?.isCancel &&
|
|
44809
|
+
signal.options?.getHistory?.() === "push" &&
|
|
44810
|
+
// Nothing of this document behind: the popup was opened by the url itself
|
|
44811
|
+
// (a shared link, a bookmark). navBack would do nothing at all there, so
|
|
44812
|
+
// the entry is rewritten instead — the address must not keep saying open
|
|
44813
|
+
// about a popup that just closed.
|
|
44814
|
+
canNavBackSignal.peek()
|
|
44815
|
+
) {
|
|
44816
|
+
navBack();
|
|
44817
|
+
return;
|
|
44818
|
+
}
|
|
44819
|
+
writeInSignal(signal, false, { history: "replace" });
|
|
44820
|
+
};
|
|
44821
|
+
|
|
44822
|
+
/**
|
|
44823
|
+
* Keeps an open controller in sync with where the caller says the popup should
|
|
44824
|
+
* be: an `open`/`defaultOpen` pair, a `signal`, or a `navState` — the open
|
|
44825
|
+
* state written into the history entry, so a screen left and come back to finds
|
|
44826
|
+
* its popup as it was.
|
|
44827
|
+
*
|
|
44828
|
+
* Shared between `useOpenControllerByProps` below (Dialog/Popover driving their
|
|
44829
|
+
* own controller), `picker_custom.jsx` (which owns its controller but wants
|
|
44830
|
+
* the same skip-if-already-matching / open-or-requestClose control flow) and
|
|
44831
|
+
* `expandable.jsx` (open in flow rather than on a layer, same decision).
|
|
44832
|
+
*
|
|
44833
|
+
* @param {{ open: (e: Event, detail?: object) => void, requestClose: (e: Event, detail?: object) => void, opened: boolean }} openController
|
|
44834
|
+
* @param {{ id?: string, open?: boolean|"interaction", defaultOpen?: boolean|"interaction", signal?: import("@preact/signals").Signal<boolean>, navState?: boolean|string|{id?: string, type?: "push"|"replace"} }} props
|
|
44835
|
+
* @param {string} [name] What the dev warnings call the thing being opened.
|
|
44836
|
+
*/
|
|
44837
|
+
const useOpenPropsEffectOnOpenController = (
|
|
44838
|
+
openController,
|
|
44839
|
+
props,
|
|
44840
|
+
name = "popup",
|
|
44841
|
+
) => {
|
|
44842
|
+
const { signal, defaultOpen, navState } = props;
|
|
44843
|
+
const { id: navStateId, type: navStateType } = resolveNavStateProp(
|
|
44844
|
+
navState,
|
|
44845
|
+
props.id);
|
|
44846
|
+
// Called unconditionally (it answers with no-ops for an absent id), like
|
|
44847
|
+
// every other hook here.
|
|
44848
|
+
const [navStateValue, enterNavState, leaveNavState] = useNavState(
|
|
44849
|
+
navStateId,
|
|
44850
|
+
{ type: navStateType },
|
|
44851
|
+
);
|
|
44852
|
+
// What the caller holds, however they hold it: the history entry when there
|
|
44853
|
+
// is a `navState`, an `open` they re-render themselves, or a `signal` this
|
|
44854
|
+
// hook also writes (see onOpenedChange below). Reading .value during render
|
|
44855
|
+
// is what subscribes the popup to a signal; reading the document state is
|
|
44856
|
+
// what subscribes it to the history entry, back button included.
|
|
44857
|
+
const open = navStateId
|
|
44858
|
+
? Boolean(navStateValue)
|
|
44859
|
+
: signal
|
|
44860
|
+
? signal.value
|
|
44861
|
+
: props.open;
|
|
44862
|
+
// Assigned on every render, like openEffect, so it always closes over the
|
|
44863
|
+
// latest prop: a popup that opens or closes on its own (Escape, backdrop, a
|
|
44864
|
+
// --navi-close command) writes what happened where the caller keeps it, so
|
|
44865
|
+
// whoever holds it always reads where the popup is.
|
|
44866
|
+
openController.onOpenedChange =
|
|
44867
|
+
navStateId || signal
|
|
44868
|
+
? (opened, event) => {
|
|
44869
|
+
if (navStateId) {
|
|
44870
|
+
if (opened) {
|
|
44871
|
+
enterNavState();
|
|
44872
|
+
} else {
|
|
44873
|
+
// Under type "push" a cancel goes back rather than rewriting the
|
|
44874
|
+
// entry, so everything else written to the url while the popup
|
|
44875
|
+
// was open goes back with it (see useNavState's own leave()).
|
|
44876
|
+
leaveNavState({ isBack: Boolean(event?.detail?.isCancel) });
|
|
44877
|
+
}
|
|
44878
|
+
}
|
|
44879
|
+
if (signal) {
|
|
44880
|
+
writeOpenedInSignal(signal, opened, event);
|
|
44881
|
+
}
|
|
44882
|
+
}
|
|
44883
|
+
: null;
|
|
44884
|
+
// Tracks whether the effect below has ever run before — only the very
|
|
44885
|
+
// first run gets the "mount already open" treatment (`open` truthy from
|
|
44886
|
+
// the start, or the uncontrolled, mount-only `defaultOpen`); every
|
|
44887
|
+
// subsequent `open` change is a real, later toggle and should animate
|
|
44888
|
+
// normally like any other interactive open/close.
|
|
44889
|
+
const isFirstRunRef = useRef(true);
|
|
44890
|
+
|
|
44891
|
+
useLayoutEffect(() => {
|
|
44892
|
+
const isFirstRun = isFirstRunRef.current;
|
|
44893
|
+
isFirstRunRef.current = false;
|
|
44894
|
+
|
|
44895
|
+
if (isFirstRun) {
|
|
44896
|
+
const mountOpenReason = open || defaultOpen;
|
|
44897
|
+
if (mountOpenReason) {
|
|
44898
|
+
// Whether this popup being open is something that just happened, or
|
|
44899
|
+
// something that was already true when the page appeared. "interaction"
|
|
44900
|
+
// says the mount IS the opening — the popup exists because the user
|
|
44901
|
+
// just asked for it — so the entrance plays like any other open. Any
|
|
44902
|
+
// other truthy value means it was simply already open: nothing was ever
|
|
44903
|
+
// shown as "closed" for the user to see it transition away from, so the
|
|
44904
|
+
// entrance is skipped (`silent`, see popover.jsx's own openEffect).
|
|
44905
|
+
//
|
|
44906
|
+
// Deferred + batched (see scheduleMountOpen above) rather than called
|
|
44907
|
+
// directly, so nested popups that both mount already-open end up
|
|
44908
|
+
// stacked ancestor-first instead of Preact's own child-first effect
|
|
44909
|
+
// order.
|
|
44910
|
+
scheduleMountOpen(() =>
|
|
44911
|
+
openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
|
|
44912
|
+
silent: mountOpenReason !== "interaction",
|
|
44913
|
+
}),
|
|
44914
|
+
);
|
|
44915
|
+
}
|
|
44916
|
+
return;
|
|
44917
|
+
}
|
|
44918
|
+
|
|
44919
|
+
if (open === undefined) {
|
|
44920
|
+
return;
|
|
44921
|
+
}
|
|
44922
|
+
// Skip when the controller is already in the desired state.
|
|
44923
|
+
// openController.opened tracks actual open/close (updated by onopen/onclose,
|
|
44924
|
+
// not by renders) so it is the authoritative check against feedback loops.
|
|
44925
|
+
if (open === openController.opened) {
|
|
44926
|
+
return;
|
|
44927
|
+
}
|
|
44928
|
+
if (open) {
|
|
44929
|
+
openController.open(new CustomEvent("open_by_prop", { detail: {} }));
|
|
44930
|
+
} else {
|
|
44931
|
+
openController.requestClose(
|
|
44932
|
+
new CustomEvent("close_by_prop", { detail: {} }),
|
|
44933
|
+
{ isCancel: true },
|
|
44934
|
+
);
|
|
44935
|
+
}
|
|
44936
|
+
// The request can be refused (a busy form denying the close): the popup
|
|
44937
|
+
// then stays where it was, and whoever holds the open state is told so —
|
|
44938
|
+
// otherwise it would keep saying "closed" about a popup still open.
|
|
44939
|
+
// Written over rather than stacked on: a refusal corrects the state that
|
|
44940
|
+
// asked, it is not a place one came from.
|
|
44941
|
+
if (signal) {
|
|
44942
|
+
writeInSignal(signal, openController.opened, { history: "replace" });
|
|
44943
|
+
}
|
|
44944
|
+
if (navStateId && openController.opened) {
|
|
44945
|
+
enterNavState();
|
|
44946
|
+
}
|
|
44947
|
+
}, [open]);
|
|
44948
|
+
};
|
|
44949
|
+
|
|
44950
|
+
const useOpenControllerByProps = (props, name) => {
|
|
44951
|
+
const { onClose } = props;
|
|
44952
|
+
// Lets an uncontrolled consumer (no openController of its own) still react
|
|
44953
|
+
// to a self-initiated close (Escape, backdrop click, its own close button)
|
|
44954
|
+
// without having to own a controller just to observe it — onClose is
|
|
44955
|
+
// called on every real close, matching createOpenController's own
|
|
44956
|
+
// { onRequestClose, onClose } contract (never denies the close itself).
|
|
44957
|
+
const openController = useOpenController(() =>
|
|
44958
|
+
onClose ? { onClose } : undefined,
|
|
44959
|
+
);
|
|
44960
|
+
useOpenPropsEffectOnOpenController(openController, props, name);
|
|
44961
|
+
return openController;
|
|
44962
|
+
};
|
|
44963
|
+
|
|
43903
44964
|
/**
|
|
43904
44965
|
* Runs `fn` and commits whatever it re-renders before returning, instead of
|
|
43905
44966
|
* letting Preact batch it into the next microtask. Layout effects of what gets
|
|
@@ -44188,7 +45249,7 @@ const MOUNT_DEFAULT = "from-first-open";
|
|
|
44188
45249
|
const usePopupContentMount = (
|
|
44189
45250
|
openController,
|
|
44190
45251
|
ref,
|
|
44191
|
-
{
|
|
45252
|
+
{ mount = MOUNT_DEFAULT },
|
|
44192
45253
|
) => {
|
|
44193
45254
|
const mountedAlways = mount === "always";
|
|
44194
45255
|
const [contentMounted, setContentMounted] = useState(
|
|
@@ -44228,7 +45289,7 @@ const usePopupContentMount = (
|
|
|
44228
45289
|
}
|
|
44229
45290
|
}, [mountedAlways]);
|
|
44230
45291
|
|
|
44231
|
-
return contentMounted
|
|
45292
|
+
return contentMounted;
|
|
44232
45293
|
};
|
|
44233
45294
|
|
|
44234
45295
|
installImportMetaCssBuild(import.meta);/**
|
|
@@ -44258,10 +45319,17 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
44258
45319
|
*
|
|
44259
45320
|
* The root never looks at its children: which parts are there, in which order,
|
|
44260
45321
|
* is none of its business. It publishes a context and the parts take what they
|
|
44261
|
-
* need from it
|
|
44262
|
-
*
|
|
44263
|
-
*
|
|
44264
|
-
*
|
|
45322
|
+
* need from it.
|
|
45323
|
+
*
|
|
45324
|
+
* Deciding to open or to close is the same decision a Dialog or a Popover
|
|
45325
|
+
* takes — the same props behind it (`open`/`defaultOpen`/`signal`/`navState`),
|
|
45326
|
+
* the same commands in front of it, the same refusable close — so it is taken
|
|
45327
|
+
* in the same place: an open controller (see open_controller.js). What belongs
|
|
45328
|
+
* to an expandable is only what opening LOOKS like, which is `openEffect` and
|
|
45329
|
+
* the cleanup it returns. Both render synchronously (flushSyncRendering)
|
|
45330
|
+
* before measuring, so the DOM is in the state they are about to measure —
|
|
45331
|
+
* that, and not any inspection of the children, is what makes a part free to
|
|
45332
|
+
* re-render later than the root (a memoized subtree does exactly that).
|
|
44265
45333
|
*
|
|
44266
45334
|
* Reach for it knowingly: expanding in-flow SHIFTS the layout — everything
|
|
44267
45335
|
* below (or beside) moves when it opens. A Popover, Dialog, Picker or Callout
|
|
@@ -44281,17 +45349,18 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
44281
45349
|
* and answer the `navi_command`/`navi_request_open`/`navi_request_close`
|
|
44282
45350
|
* events.
|
|
44283
45351
|
*
|
|
44284
|
-
* Content is not built until the first expansion and stays built afterwards
|
|
44285
|
-
*
|
|
44286
|
-
* `"always"` builds it right away, `"while-opened"`
|
|
44287
|
-
* collapse settles
|
|
45352
|
+
* Content is not built until the first expansion and stays built afterwards:
|
|
45353
|
+
* a popup's policy, and its code (`usePopupContentMount`, see
|
|
45354
|
+
* popup_content_mount.js) — `"always"` builds it right away, `"while-opened"`
|
|
45355
|
+
* throws it away once the collapse settles, so a closing animation still plays
|
|
45356
|
+
* on real content.
|
|
44288
45357
|
*
|
|
44289
45358
|
* The animation is a REVEAL, not a resize: the expandable's own footprint
|
|
44290
45359
|
* grows/shrinks progressively (the content's grid track interpolates
|
|
44291
45360
|
* 0fr <-> 1fr — rows for the stacked layout, columns for `layout="column"`),
|
|
44292
45361
|
* but the content inside is laid out at its final size for the whole movement
|
|
44293
|
-
* (its animated dimension is frozen to the measured final value, see
|
|
44294
|
-
*
|
|
45362
|
+
* (its animated dimension is frozen to the measured final value, see
|
|
45363
|
+
* freezeContentSize) and the container simply uncovers it. Text never rewraps
|
|
44295
45364
|
* mid-animation. The content is revealed from its UI side (pinned against the
|
|
44296
45365
|
* UI when it comes first). Once settled open the clipping is released, so a
|
|
44297
45366
|
* popover or focus ring inside is not cut at the edges.
|
|
@@ -44443,6 +45512,8 @@ const useExpandableContext = partName => {
|
|
|
44443
45512
|
* open?: boolean,
|
|
44444
45513
|
* defaultOpen?: boolean,
|
|
44445
45514
|
* signal?: import("@preact/signals").Signal<boolean>,
|
|
45515
|
+
* navState?: boolean | string | { id?: string, type?: "push" | "replace" },
|
|
45516
|
+
* onClose?: (event: Event) => void,
|
|
44446
45517
|
* onToggle?: (event: Event) => void,
|
|
44447
45518
|
* action?: Function,
|
|
44448
45519
|
* loading?: boolean,
|
|
@@ -44464,10 +45535,19 @@ const useExpandableContext = partName => {
|
|
|
44464
45535
|
* `<Expandable.Content>` as children instead.
|
|
44465
45536
|
* @param open - Drives the state from outside: the expandable opens/closes to
|
|
44466
45537
|
* match every change of this prop, but user interaction can still toggle it
|
|
44467
|
-
* in between
|
|
45538
|
+
* in between. Same open controller as Dialog/Popover, so the four props
|
|
45539
|
+
* below behave exactly as they do there (see open_controller.js).
|
|
44468
45540
|
* @param defaultOpen - Uncontrolled, mount-only initial state.
|
|
44469
45541
|
* @param signal - Two-way binding: the expandable follows the signal and
|
|
44470
45542
|
* writes back into it whenever it toggles on its own. Excludes `open`.
|
|
45543
|
+
* @param navState - Keeps the open state in the history entry, so a screen
|
|
45544
|
+
* left and come back to finds its sections as they were: `true` uses the
|
|
45545
|
+
* expandable's own `id`, a string names the key, `{ id, type }` chooses
|
|
45546
|
+
* between rewriting the entry ("replace", the default) and pushing one
|
|
45547
|
+
* ("push"), where closing goes back. Source of truth — excludes
|
|
45548
|
+
* `open`/`signal`.
|
|
45549
|
+
* @param onClose - Called when the expandable actually closes, whatever asked
|
|
45550
|
+
* for it. Not preventable.
|
|
44471
45551
|
* @param onToggle - Listens the "toggle" event dispatched on the root (a
|
|
44472
45552
|
* ToggleEvent with newState/oldState where supported). Fires on every actual
|
|
44473
45553
|
* state change, never on mount.
|
|
@@ -44487,15 +45567,17 @@ const useExpandableContext = partName => {
|
|
|
44487
45567
|
* whatever order they are written in — and an expandable with no
|
|
44488
45568
|
* `<Expandable.UI>` at all, driven from `open`/`signal` by a toggle of the
|
|
44489
45569
|
* app's own, can still say which way it opens.
|
|
44490
|
-
* @param autoFocus - Off by default (the
|
|
44491
|
-
*
|
|
44492
|
-
*
|
|
44493
|
-
*
|
|
44494
|
-
*
|
|
45570
|
+
* @param autoFocus - Off by default (the keyboard stays where it is when the
|
|
45571
|
+
* expandable opens). `true` hands the focus to the content through the same
|
|
45572
|
+
* transfer a popup uses — the ladder picks the target (`navi-autofocus`
|
|
45573
|
+
* first, see focus_transfer.js) and closing gives the keyboard back where it
|
|
45574
|
+
* came from. An expandable that mounts already open never takes it. Whatever
|
|
45575
|
+
* the setting, closing while the focus sits inside the content hands it back
|
|
45576
|
+
* to the UI part (it would otherwise be lost to the closed, inert content).
|
|
44495
45577
|
* @param maxContentHeight - Caps the content height; taller content scrolls
|
|
44496
45578
|
* inside the expandable instead of growing it.
|
|
44497
|
-
* @param mount - When the content is built and thrown away,
|
|
44498
|
-
*
|
|
45579
|
+
* @param mount - When the content is built and thrown away, a popup's own
|
|
45580
|
+
* three values and a popup's own code (see popup_content_mount.js). `"from-first-open"` (the
|
|
44499
45581
|
* default) builds it on the first expansion and keeps it afterwards.
|
|
44500
45582
|
* `"always"` builds it right away; in layout="column" it also gives the
|
|
44501
45583
|
* closed expandable its content's height (the content is kept laid out at
|
|
@@ -44506,12 +45588,17 @@ const useExpandableContext = partName => {
|
|
|
44506
45588
|
*/
|
|
44507
45589
|
const Expandable = props => {
|
|
44508
45590
|
import.meta.css = [css$Q, "@jsenv/navi/src/control/expandable/expandable.jsx"];
|
|
45591
|
+
/* The open props are read from `props` by the open controller below; they
|
|
45592
|
+
are named here only to keep them out of `rest`, and so out of the DOM. */
|
|
45593
|
+
/* eslint-disable no-unused-vars */
|
|
44509
45594
|
const {
|
|
44510
45595
|
ref,
|
|
44511
45596
|
ui,
|
|
44512
45597
|
open,
|
|
44513
45598
|
defaultOpen,
|
|
44514
45599
|
signal,
|
|
45600
|
+
navState,
|
|
45601
|
+
onClose,
|
|
44515
45602
|
action,
|
|
44516
45603
|
loading,
|
|
44517
45604
|
animation = false,
|
|
@@ -44526,35 +45613,43 @@ const Expandable = props => {
|
|
|
44526
45613
|
children,
|
|
44527
45614
|
...rest
|
|
44528
45615
|
} = props;
|
|
45616
|
+
/* eslint-enable no-unused-vars */
|
|
45617
|
+
|
|
44529
45618
|
const defaultRef = useRef();
|
|
44530
45619
|
const rootRef = ref || defaultRef;
|
|
44531
45620
|
const uiRef = useRef();
|
|
44532
45621
|
const contentContainerRef = useRef();
|
|
44533
45622
|
const contentId = useId();
|
|
44534
45623
|
const isColumn = layout === "column";
|
|
44535
|
-
const
|
|
44536
|
-
const mountedWhileOpened = mount === "while-opened";
|
|
44537
|
-
const closedContentSized = isColumn && mountedAlways;
|
|
44538
|
-
// Reading .value during render is what subscribes the expandable to it.
|
|
44539
|
-
const openRequested = signal ? signal.value : open;
|
|
44540
|
-
const [opened, setOpened] = useState(() => Boolean(openRequested === undefined ? defaultOpen : openRequested));
|
|
44541
|
-
const openedRef = useRef(opened);
|
|
44542
|
-
openedRef.current = opened;
|
|
45624
|
+
const closedContentSized = isColumn && mount === "always";
|
|
44543
45625
|
const hasAction = Boolean(action);
|
|
44544
45626
|
const effectiveAction = useAction(action);
|
|
44545
45627
|
const {
|
|
44546
45628
|
loading: actionLoading
|
|
44547
45629
|
} = useActionStatus(effectiveAction);
|
|
44548
|
-
const [contentMounted, setContentMounted] = useState(() => mountedAlways || opened);
|
|
44549
45630
|
|
|
44550
|
-
//
|
|
44551
|
-
// (see
|
|
45631
|
+
// Registered before the open controller, so this cleanup runs before its own
|
|
45632
|
+
// unmount safety net (see useOpenController): a close fired on the way out
|
|
45633
|
+
// still does its bookkeeping, it just has no tree left to move.
|
|
45634
|
+
const unmountedRef = useRef(false);
|
|
45635
|
+
useLayoutEffect(() => {
|
|
45636
|
+
return () => {
|
|
45637
|
+
unmountedRef.current = true;
|
|
45638
|
+
};
|
|
45639
|
+
}, []);
|
|
45640
|
+
const openController = useOpenControllerByProps(props, "expandable");
|
|
45641
|
+
// What the controller decided, mirrored for the render (aria-expanded, the
|
|
45642
|
+
// content's inert, the marker). Written only from `openEffect` and the
|
|
45643
|
+
// cleanup it returns, and synchronously (see flushSyncRendering), so the DOM
|
|
45644
|
+
// is already in the new state when what follows measures it.
|
|
45645
|
+
const [opened, setOpened] = useState(false);
|
|
45646
|
+
// Fully open (or fully closed) and no longer moving — what releases the
|
|
45647
|
+
// clipping, see the CSS.
|
|
44552
45648
|
const [settled, setSettled] = useState(true);
|
|
45649
|
+
const contentMounted = usePopupContentMount(openController, contentContainerRef, {
|
|
45650
|
+
mount
|
|
45651
|
+
});
|
|
44553
45652
|
|
|
44554
|
-
// Read before the close touches the DOM: flipping the content to inert can
|
|
44555
|
-
// blur what it held, so by effect time the focus to hand back to the UI part
|
|
44556
|
-
// could already be gone.
|
|
44557
|
-
const focusedBeforeCloseRef = useRef(null);
|
|
44558
45653
|
// The pointer press that is about to toggle can blur the focused field
|
|
44559
45654
|
// before the click ever fires — so what held the focus has to be remembered
|
|
44560
45655
|
// at pointerdown time.
|
|
@@ -44568,10 +45663,7 @@ const Expandable = props => {
|
|
|
44568
45663
|
// released once the movement settles.
|
|
44569
45664
|
const freezeContentSize = () => {
|
|
44570
45665
|
const contentContainer = contentContainerRef.current;
|
|
44571
|
-
const contentElement = contentContainer
|
|
44572
|
-
if (!contentElement) {
|
|
44573
|
-
return;
|
|
44574
|
-
}
|
|
45666
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
44575
45667
|
const rect = contentElement.getBoundingClientRect();
|
|
44576
45668
|
if (isColumn) {
|
|
44577
45669
|
// Both, not just the width: a max-height-capped content otherwise
|
|
@@ -44583,50 +45675,147 @@ const Expandable = props => {
|
|
|
44583
45675
|
}
|
|
44584
45676
|
};
|
|
44585
45677
|
|
|
44586
|
-
//
|
|
44587
|
-
//
|
|
44588
|
-
//
|
|
44589
|
-
|
|
44590
|
-
|
|
44591
|
-
|
|
44592
|
-
|
|
44593
|
-
|
|
44594
|
-
|
|
44595
|
-
|
|
44596
|
-
|
|
45678
|
+
// A movement still playing: what a new one cancels before starting, so a
|
|
45679
|
+
// reveal interrupted halfway does not have the previous settle release its
|
|
45680
|
+
// frozen size under it.
|
|
45681
|
+
const cancelSettleWatchRef = useRef(null);
|
|
45682
|
+
const cancelSettleWatch = () => {
|
|
45683
|
+
cancelSettleWatchRef.current?.();
|
|
45684
|
+
cancelSettleWatchRef.current = null;
|
|
45685
|
+
};
|
|
45686
|
+
const watchSettle = () => {
|
|
45687
|
+
const contentContainer = contentContainerRef.current;
|
|
45688
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
45689
|
+
cancelSettleWatchRef.current = whenTransitionSettles(contentContainer, () => {
|
|
45690
|
+
cancelSettleWatchRef.current = null;
|
|
45691
|
+
contentElement.style.width = "";
|
|
45692
|
+
contentElement.style.height = "";
|
|
45693
|
+
setSettled(true);
|
|
45694
|
+
});
|
|
45695
|
+
};
|
|
45696
|
+
|
|
45697
|
+
// The reveal needs the content at its final size before the track starts
|
|
45698
|
+
// moving, and that size only exists in the open state — the reflow trick
|
|
45699
|
+
// (see instructions.md, CSS section), with transitions suppressed BEFORE the
|
|
45700
|
+
// first layout read: this runs pre-paint, so any earlier read would itself be
|
|
45701
|
+
// the first recalc of the open state and would start the track transition.
|
|
45702
|
+
const armReveal = startRect => {
|
|
45703
|
+
const contentContainer = contentContainerRef.current;
|
|
45704
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
45705
|
+
contentContainer.style.transitionProperty = "none";
|
|
45706
|
+
const finalRect = contentElement.getBoundingClientRect();
|
|
45707
|
+
if (isColumn) {
|
|
45708
|
+
contentElement.style.width = `${finalRect.width}px`;
|
|
45709
|
+
contentElement.style.height = `${finalRect.height}px`;
|
|
45710
|
+
} else {
|
|
45711
|
+
contentElement.style.height = `${finalRect.height}px`;
|
|
44597
45712
|
}
|
|
44598
|
-
|
|
44599
|
-
|
|
44600
|
-
|
|
44601
|
-
|
|
44602
|
-
|
|
45713
|
+
// Put the tracks back where the last paint left them and let the
|
|
45714
|
+
// transition play from there. In fr — px does not interpolate with fr.
|
|
45715
|
+
const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
|
|
45716
|
+
if (isColumn) {
|
|
45717
|
+
contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
|
|
45718
|
+
if (!closedContentSized) {
|
|
45719
|
+
// The height opens alongside the width (a closed column expandable is
|
|
45720
|
+
// only as tall as its UI) — unless the closed content already sizes
|
|
45721
|
+
// it, where only the width has anywhere to go.
|
|
45722
|
+
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
44603
45723
|
}
|
|
44604
|
-
setContentMounted(true);
|
|
44605
45724
|
} else {
|
|
44606
|
-
|
|
44607
|
-
focusedBeforeCloseRef.current = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
|
|
44608
|
-
if (animation) {
|
|
44609
|
-
// Now, while the content is still fully laid out — by effect time the
|
|
44610
|
-
// track is already heading to 0 (the opening case measures in the
|
|
44611
|
-
// effect instead, where the just-mounted content exists).
|
|
44612
|
-
freezeContentSize();
|
|
44613
|
-
}
|
|
44614
|
-
}
|
|
44615
|
-
focusedAtPointerDownRef.current = null;
|
|
44616
|
-
setOpened(nextOpen);
|
|
44617
|
-
// Flipped here, before the closing/opening commit, so effects of that very
|
|
44618
|
-
// commit already see the movement as started — mount="while-opened" must not
|
|
44619
|
-
// read a stale "settled" and empty the content under a closing animation.
|
|
44620
|
-
setSettled(!animation);
|
|
44621
|
-
if (signal) {
|
|
44622
|
-
signal.value = nextOpen;
|
|
45725
|
+
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
44623
45726
|
}
|
|
45727
|
+
// That starting frame must be genuinely rendered to transition from it,
|
|
45728
|
+
// and transitions re-enabled BEFORE the flip back to the open value —
|
|
45729
|
+
// same order as popover.jsx's own reflow trick.
|
|
45730
|
+
contentContainer.getBoundingClientRect();
|
|
45731
|
+
contentContainer.style.transitionProperty = "";
|
|
45732
|
+
contentContainer.style.gridTemplateColumns = "";
|
|
45733
|
+
contentContainer.style.gridTemplateRows = "";
|
|
45734
|
+
};
|
|
45735
|
+
|
|
45736
|
+
// What opening LOOKS like here, and how to undo it — the one thing an
|
|
45737
|
+
// expandable owns that a popup does not (see open_controller.js). Reassigned
|
|
45738
|
+
// on every render so it always closes over the latest props.
|
|
45739
|
+
openController.openEffect = openEvent => {
|
|
45740
|
+
const contentContainer = contentContainerRef.current;
|
|
45741
|
+
// `silent`: the expandable was already open when the page appeared
|
|
45742
|
+
// (`open`/`defaultOpen` at mount). Nothing changed for anyone to be told
|
|
45743
|
+
// about, nothing was ever shown closed to move away from, and a page must
|
|
45744
|
+
// not have its focus stolen by a section that was simply already open — so
|
|
45745
|
+
// an opening that was never an opening plays nothing, says nothing and
|
|
45746
|
+
// takes nothing. The action it carries still runs: its content is due.
|
|
45747
|
+
const silent = Boolean(openEvent.detail.silent);
|
|
45748
|
+
const revealing = animation && !silent && Boolean(contentContainer);
|
|
45749
|
+
// Where the last paint left the track — 0 when fully closed, partway when
|
|
45750
|
+
// reopening during a collapse — read while the DOM still says closed.
|
|
45751
|
+
const startRect = revealing ? contentContainer.getBoundingClientRect() : null;
|
|
45752
|
+
cancelSettleWatch();
|
|
45753
|
+
flushSyncRendering(() => {
|
|
45754
|
+
setOpened(true);
|
|
45755
|
+
setSettled(!revealing);
|
|
45756
|
+
});
|
|
44624
45757
|
if (hasAction) {
|
|
44625
|
-
|
|
44626
|
-
|
|
44627
|
-
|
|
45758
|
+
runUnwatched(() => effectiveAction.run());
|
|
45759
|
+
}
|
|
45760
|
+
// autoFocus off (the default): the keyboard stays where it is, and with no
|
|
45761
|
+
// transfer there is nothing to restore on close either.
|
|
45762
|
+
const restoreFocus = autoFocus && !silent && contentContainer ? openController.transferFocusOnOpen(contentContainer) : null;
|
|
45763
|
+
if (revealing) {
|
|
45764
|
+
armReveal(startRect);
|
|
45765
|
+
watchSettle();
|
|
45766
|
+
}
|
|
45767
|
+
if (!silent) {
|
|
45768
|
+
rootRef.current.dispatchEvent(createToggleEvent(true));
|
|
45769
|
+
}
|
|
45770
|
+
return closeEvent => {
|
|
45771
|
+
if (unmountedRef.current) {
|
|
45772
|
+
return;
|
|
45773
|
+
}
|
|
45774
|
+
const contentContainerAtClose = contentContainerRef.current;
|
|
45775
|
+
// Read while the content still holds what it holds: the close is about
|
|
45776
|
+
// to make it inert, which blurs whatever is inside it.
|
|
45777
|
+
const activeElement = document.activeElement;
|
|
45778
|
+
const focusedBeforeClose = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
|
|
45779
|
+
focusedAtPointerDownRef.current = null;
|
|
45780
|
+
if (hasAction) {
|
|
44628
45781
|
effectiveAction.abort();
|
|
44629
45782
|
}
|
|
45783
|
+
cancelSettleWatch();
|
|
45784
|
+
const collapsing = animation && Boolean(contentContainerAtClose);
|
|
45785
|
+
if (collapsing) {
|
|
45786
|
+
// Now, while the content is still fully laid out — the collapsing
|
|
45787
|
+
// track uncovers a content frozen at that size.
|
|
45788
|
+
freezeContentSize();
|
|
45789
|
+
}
|
|
45790
|
+
flushSyncRendering(() => {
|
|
45791
|
+
setOpened(false);
|
|
45792
|
+
setSettled(!collapsing);
|
|
45793
|
+
});
|
|
45794
|
+
if (restoreFocus) {
|
|
45795
|
+
restoreFocus(closeEvent);
|
|
45796
|
+
} else if (focusedBeforeClose && contentContainerAtClose && contentContainerAtClose.contains(focusedBeforeClose)) {
|
|
45797
|
+
const uiElement = uiRef.current;
|
|
45798
|
+
if (uiElement) {
|
|
45799
|
+
moveFocusTo(uiElement);
|
|
45800
|
+
} else {
|
|
45801
|
+
// Nothing of the expandable's own can hold the keyboard (no UI
|
|
45802
|
+
// part): the focus only has to leave the content becoming inert.
|
|
45803
|
+
focusedBeforeClose.blur();
|
|
45804
|
+
}
|
|
45805
|
+
}
|
|
45806
|
+
if (collapsing) {
|
|
45807
|
+
watchSettle();
|
|
45808
|
+
}
|
|
45809
|
+
rootRef.current.dispatchEvent(createToggleEvent(false));
|
|
45810
|
+
};
|
|
45811
|
+
};
|
|
45812
|
+
const toggle = event => {
|
|
45813
|
+
if (openController.opened) {
|
|
45814
|
+
openController.requestClose(event, {
|
|
45815
|
+
isCancel: true
|
|
45816
|
+
});
|
|
45817
|
+
} else {
|
|
45818
|
+
openController.open(event);
|
|
44630
45819
|
}
|
|
44631
45820
|
};
|
|
44632
45821
|
const findFirstFocusableInContent = () => {
|
|
@@ -44634,133 +45823,11 @@ const Expandable = props => {
|
|
|
44634
45823
|
if (!contentContainer) {
|
|
44635
45824
|
return null;
|
|
44636
45825
|
}
|
|
44637
|
-
const autofocusElement = contentContainer.querySelector("[autofocus]");
|
|
44638
|
-
if (autofocusElement) {
|
|
44639
|
-
return autofocusElement;
|
|
44640
|
-
}
|
|
44641
45826
|
return findAfter(contentContainer, elementIsFocusable, {
|
|
44642
45827
|
root: contentContainer
|
|
44643
45828
|
});
|
|
44644
45829
|
};
|
|
44645
45830
|
|
|
44646
|
-
// Follow `open`/`signal` changes after mount (the initial value is already
|
|
44647
|
-
// in the state above). A self-initiated toggle that wrote the signal lands
|
|
44648
|
-
// here too and no-ops, since the state already matches.
|
|
44649
|
-
const isFirstOpenRequestedRunRef = useRef(true);
|
|
44650
|
-
useLayoutEffect(() => {
|
|
44651
|
-
if (isFirstOpenRequestedRunRef.current) {
|
|
44652
|
-
isFirstOpenRequestedRunRef.current = false;
|
|
44653
|
-
return;
|
|
44654
|
-
}
|
|
44655
|
-
if (openRequested === undefined) {
|
|
44656
|
-
return;
|
|
44657
|
-
}
|
|
44658
|
-
toggleTo(openRequested);
|
|
44659
|
-
}, [openRequested]);
|
|
44660
|
-
|
|
44661
|
-
// A state change: tell the world (the "toggle" event) and take the keyboard
|
|
44662
|
-
// back if the closing content held it. Skipped on mount — nothing changed,
|
|
44663
|
-
// so no event exists (and a page must not have its focus stolen by an
|
|
44664
|
-
// expandable that was simply already open).
|
|
44665
|
-
const isFirstOpenedRunRef = useRef(true);
|
|
44666
|
-
useLayoutEffect(() => {
|
|
44667
|
-
if (isFirstOpenedRunRef.current) {
|
|
44668
|
-
isFirstOpenedRunRef.current = false;
|
|
44669
|
-
return;
|
|
44670
|
-
}
|
|
44671
|
-
const root = rootRef.current;
|
|
44672
|
-
root.dispatchEvent(createToggleEvent(opened));
|
|
44673
|
-
if (opened) {
|
|
44674
|
-
return;
|
|
44675
|
-
}
|
|
44676
|
-
const focusedBeforeClose = focusedBeforeCloseRef.current;
|
|
44677
|
-
focusedBeforeCloseRef.current = null;
|
|
44678
|
-
if (focusedBeforeClose && contentContainerRef.current && contentContainerRef.current.contains(focusedBeforeClose)) {
|
|
44679
|
-
const uiElement = uiRef.current;
|
|
44680
|
-
if (uiElement) {
|
|
44681
|
-
uiElement.focus();
|
|
44682
|
-
} else {
|
|
44683
|
-
// Nothing of the expandable's own can hold the keyboard (no UI part):
|
|
44684
|
-
// the focus only has to leave the content becoming inert.
|
|
44685
|
-
focusedBeforeClose.blur();
|
|
44686
|
-
}
|
|
44687
|
-
}
|
|
44688
|
-
}, [opened]);
|
|
44689
|
-
|
|
44690
|
-
// Everything a state change owes the content: the focus it may take, and the
|
|
44691
|
-
// reveal, which measures it. Ran by <Expandable.Content> from its own layout
|
|
44692
|
-
// effect — the commit where the content really is in the DOM — and returns
|
|
44693
|
-
// that effect's cleanup.
|
|
44694
|
-
const applyOpenedToContent = () => {
|
|
44695
|
-
if (opened && autoFocus) {
|
|
44696
|
-
const firstFocusableElement = findFirstFocusableInContent();
|
|
44697
|
-
if (firstFocusableElement) {
|
|
44698
|
-
firstFocusableElement.focus();
|
|
44699
|
-
}
|
|
44700
|
-
}
|
|
44701
|
-
if (!animation) {
|
|
44702
|
-
return undefined;
|
|
44703
|
-
}
|
|
44704
|
-
const contentContainer = contentContainerRef.current;
|
|
44705
|
-
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
44706
|
-
if (opened) {
|
|
44707
|
-
// The reveal needs the content at its final size before the track
|
|
44708
|
-
// starts moving, and the final size only exists in the open state —
|
|
44709
|
-
// the reflow trick (see instructions.md, CSS section), with transitions
|
|
44710
|
-
// suppressed BEFORE the first layout read: this runs pre-paint, so any
|
|
44711
|
-
// earlier read would itself be the first recalc of the open state and
|
|
44712
|
-
// would start the track transition (see revealStartSizeRef).
|
|
44713
|
-
contentContainer.style.transitionProperty = "none";
|
|
44714
|
-
const finalRect = contentElement.getBoundingClientRect();
|
|
44715
|
-
if (isColumn) {
|
|
44716
|
-
contentElement.style.width = `${finalRect.width}px`;
|
|
44717
|
-
contentElement.style.height = `${finalRect.height}px`;
|
|
44718
|
-
} else {
|
|
44719
|
-
contentElement.style.height = `${finalRect.height}px`;
|
|
44720
|
-
}
|
|
44721
|
-
// Put the tracks back where the last paint left them and let the
|
|
44722
|
-
// transition play from there. In fr — px does not interpolate with fr.
|
|
44723
|
-
const startRect = revealStartSizeRef.current;
|
|
44724
|
-
revealStartSizeRef.current = null;
|
|
44725
|
-
const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
|
|
44726
|
-
if (isColumn) {
|
|
44727
|
-
contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
|
|
44728
|
-
if (!closedContentSized) {
|
|
44729
|
-
// The height opens alongside the width (a closed column expandable
|
|
44730
|
-
// is only as tall as its UI) — unless the closed content already
|
|
44731
|
-
// sizes it, where only the width has anywhere to go.
|
|
44732
|
-
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
44733
|
-
}
|
|
44734
|
-
} else {
|
|
44735
|
-
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
44736
|
-
}
|
|
44737
|
-
// That starting frame must be genuinely rendered to transition from it,
|
|
44738
|
-
// and transitions re-enabled BEFORE the flip back to the open value —
|
|
44739
|
-
// same order as popover.jsx's own reflow trick.
|
|
44740
|
-
contentContainer.getBoundingClientRect();
|
|
44741
|
-
contentContainer.style.transitionProperty = "";
|
|
44742
|
-
contentContainer.style.gridTemplateColumns = "";
|
|
44743
|
-
contentContainer.style.gridTemplateRows = "";
|
|
44744
|
-
}
|
|
44745
|
-
// (closing froze the content in toggleTo, while it was still laid out)
|
|
44746
|
-
const cancel = whenTransitionSettles(contentContainer, () => {
|
|
44747
|
-
contentElement.style.width = "";
|
|
44748
|
-
contentElement.style.height = "";
|
|
44749
|
-
setSettled(true);
|
|
44750
|
-
});
|
|
44751
|
-
return cancel;
|
|
44752
|
-
};
|
|
44753
|
-
useLayoutEffect(() => {
|
|
44754
|
-
if (settled && !opened && mountedWhileOpened) {
|
|
44755
|
-
setContentMounted(false);
|
|
44756
|
-
}
|
|
44757
|
-
}, [settled, opened, mountedWhileOpened]);
|
|
44758
|
-
useLayoutEffect(() => {
|
|
44759
|
-
if (mountedAlways) {
|
|
44760
|
-
setContentMounted(true);
|
|
44761
|
-
}
|
|
44762
|
-
}, [mountedAlways]);
|
|
44763
|
-
|
|
44764
45831
|
// closedContentSized (column + mount="always"): the closed content sizes
|
|
44765
45832
|
// the height (see the CSS), which is only right if it lies at its OPEN
|
|
44766
45833
|
// width — at its natural closed width (a 0-wide track) it would wrap
|
|
@@ -44784,13 +45851,6 @@ const Expandable = props => {
|
|
|
44784
45851
|
contentContainer.getBoundingClientRect();
|
|
44785
45852
|
contentContainer.style.transitionProperty = "";
|
|
44786
45853
|
}, [closedContentSized, opened, settled, contentMounted]);
|
|
44787
|
-
|
|
44788
|
-
// Mounted already open: the content is visible, its data is due.
|
|
44789
|
-
useEffect(() => {
|
|
44790
|
-
if (openedRef.current && hasAction) {
|
|
44791
|
-
runUnwatched(() => effectiveAction.run());
|
|
44792
|
-
}
|
|
44793
|
-
}, []);
|
|
44794
45854
|
const onRootKeyDown = keyboardEvent => {
|
|
44795
45855
|
if (!arrowKeyShortcuts) {
|
|
44796
45856
|
return;
|
|
@@ -44819,9 +45879,9 @@ const Expandable = props => {
|
|
|
44819
45879
|
if (document.activeElement !== uiRef.current) {
|
|
44820
45880
|
return;
|
|
44821
45881
|
}
|
|
44822
|
-
if (!
|
|
45882
|
+
if (!openController.opened) {
|
|
44823
45883
|
keyboardEvent.preventDefault();
|
|
44824
|
-
|
|
45884
|
+
openController.open(keyboardEvent);
|
|
44825
45885
|
return;
|
|
44826
45886
|
}
|
|
44827
45887
|
const firstFocusableElementInContent = findFirstFocusableInContent();
|
|
@@ -44829,20 +45889,21 @@ const Expandable = props => {
|
|
|
44829
45889
|
return;
|
|
44830
45890
|
}
|
|
44831
45891
|
keyboardEvent.preventDefault();
|
|
44832
|
-
firstFocusableElementInContent
|
|
45892
|
+
moveFocusTo(firstFocusableElementInContent);
|
|
44833
45893
|
return;
|
|
44834
45894
|
}
|
|
44835
45895
|
if (key === closeKeyShortcut) {
|
|
44836
|
-
if (!
|
|
45896
|
+
if (!openController.opened) {
|
|
44837
45897
|
return;
|
|
44838
45898
|
}
|
|
44839
45899
|
const uiElement = uiRef.current;
|
|
45900
|
+
keyboardEvent.preventDefault();
|
|
44840
45901
|
if (document.activeElement === uiElement) {
|
|
44841
|
-
|
|
44842
|
-
|
|
45902
|
+
openController.requestClose(keyboardEvent, {
|
|
45903
|
+
isCancel: true
|
|
45904
|
+
});
|
|
44843
45905
|
} else {
|
|
44844
|
-
|
|
44845
|
-
uiElement.focus();
|
|
45906
|
+
moveFocusTo(uiElement);
|
|
44846
45907
|
}
|
|
44847
45908
|
}
|
|
44848
45909
|
};
|
|
@@ -44861,7 +45922,7 @@ const Expandable = props => {
|
|
|
44861
45922
|
return;
|
|
44862
45923
|
}
|
|
44863
45924
|
}
|
|
44864
|
-
|
|
45925
|
+
toggle(clickEvent);
|
|
44865
45926
|
};
|
|
44866
45927
|
|
|
44867
45928
|
// Space/Enter on the UI part itself (role button) — a key pressed on a
|
|
@@ -44878,7 +45939,7 @@ const Expandable = props => {
|
|
|
44878
45939
|
} = keyboardEvent;
|
|
44879
45940
|
if (key === " " || key === "Enter") {
|
|
44880
45941
|
keyboardEvent.preventDefault();
|
|
44881
|
-
|
|
45942
|
+
toggle(keyboardEvent);
|
|
44882
45943
|
}
|
|
44883
45944
|
};
|
|
44884
45945
|
|
|
@@ -44896,8 +45957,7 @@ const Expandable = props => {
|
|
|
44896
45957
|
hasAction,
|
|
44897
45958
|
effectiveAction,
|
|
44898
45959
|
markerDirection,
|
|
44899
|
-
|
|
44900
|
-
toggleTo,
|
|
45960
|
+
openController,
|
|
44901
45961
|
onUIClick,
|
|
44902
45962
|
onUIPointerDown,
|
|
44903
45963
|
onUIKeyDown,
|
|
@@ -44935,11 +45995,17 @@ const Expandable = props => {
|
|
|
44935
45995
|
},
|
|
44936
45996
|
onnavi_request_open: e => {
|
|
44937
45997
|
rest.onnavi_request_open?.(e);
|
|
44938
|
-
|
|
45998
|
+
openController.open(e);
|
|
44939
45999
|
},
|
|
44940
46000
|
onnavi_request_close: e => {
|
|
44941
46001
|
rest.onnavi_request_close?.(e);
|
|
44942
|
-
|
|
46002
|
+
const closing = openController.requestClose(e, {
|
|
46003
|
+
isCancel: e.detail?.isCancel
|
|
46004
|
+
});
|
|
46005
|
+
if (!closing) {
|
|
46006
|
+
// Said back to whoever asked: --navi-close:all stops climbing here.
|
|
46007
|
+
e.preventDefault();
|
|
46008
|
+
}
|
|
44943
46009
|
},
|
|
44944
46010
|
onKeyDown: e => {
|
|
44945
46011
|
rest.onKeyDown?.(e);
|
|
@@ -44979,7 +46045,7 @@ const ExpandableUI = ({
|
|
|
44979
46045
|
opened,
|
|
44980
46046
|
loading,
|
|
44981
46047
|
markerDirection,
|
|
44982
|
-
|
|
46048
|
+
openController,
|
|
44983
46049
|
onUIClick,
|
|
44984
46050
|
onUIPointerDown,
|
|
44985
46051
|
onUIKeyDown,
|
|
@@ -45006,11 +46072,13 @@ const ExpandableUI = ({
|
|
|
45006
46072
|
onnavi_command: e => {
|
|
45007
46073
|
onNaviCommand(e);
|
|
45008
46074
|
},
|
|
45009
|
-
onnavi_request_open:
|
|
45010
|
-
|
|
46075
|
+
onnavi_request_open: e => {
|
|
46076
|
+
openController.open(e);
|
|
45011
46077
|
},
|
|
45012
|
-
onnavi_request_close:
|
|
45013
|
-
|
|
46078
|
+
onnavi_request_close: e => {
|
|
46079
|
+
openController.requestClose(e, {
|
|
46080
|
+
isCancel: e.detail?.isCancel
|
|
46081
|
+
});
|
|
45014
46082
|
},
|
|
45015
46083
|
...rest,
|
|
45016
46084
|
children: [marker === false ? null : jsx("span", {
|
|
@@ -45046,24 +46114,9 @@ const ExpandableContent = ({
|
|
|
45046
46114
|
contentMounted,
|
|
45047
46115
|
hasAction,
|
|
45048
46116
|
effectiveAction,
|
|
45049
|
-
applyOpenedToContent,
|
|
45050
46117
|
contentContainerRef,
|
|
45051
46118
|
contentId
|
|
45052
46119
|
} = useExpandableContext("Content");
|
|
45053
|
-
|
|
45054
|
-
// The focus move and the reveal are the root's, but they measure the content
|
|
45055
|
-
// and can only run once it is in the DOM — which is this commit, the one
|
|
45056
|
-
// that rendered it. Skipped on mount: nothing has changed yet, and an
|
|
45057
|
-
// expandable that was simply already open must neither animate nor take the
|
|
45058
|
-
// focus.
|
|
45059
|
-
const isFirstOpenedRunRef = useRef(true);
|
|
45060
|
-
useLayoutEffect(() => {
|
|
45061
|
-
if (isFirstOpenedRunRef.current) {
|
|
45062
|
-
isFirstOpenedRunRef.current = false;
|
|
45063
|
-
return undefined;
|
|
45064
|
-
}
|
|
45065
|
-
return applyOpenedToContent();
|
|
45066
|
-
}, [opened]);
|
|
45067
46120
|
let content = children;
|
|
45068
46121
|
if (hasAction) {
|
|
45069
46122
|
content = jsx(ActionRenderer, {
|
|
@@ -45613,6 +46666,7 @@ const ButtonUI = props => {
|
|
|
45613
46666
|
target,
|
|
45614
46667
|
rel,
|
|
45615
46668
|
replace,
|
|
46669
|
+
pressableDuringRouteTransition,
|
|
45616
46670
|
// visual
|
|
45617
46671
|
variant,
|
|
45618
46672
|
pressEffect,
|
|
@@ -45663,6 +46717,12 @@ const ButtonUI = props => {
|
|
|
45663
46717
|
const replaceRequest = replace ? {
|
|
45664
46718
|
[LINK_REPLACE_ATTRIBUTE]: ""
|
|
45665
46719
|
} : null;
|
|
46720
|
+
|
|
46721
|
+
// Worn as an attribute too, and read at the document by whoever catches the
|
|
46722
|
+
// press a movement would have swallowed (see transition_press.js).
|
|
46723
|
+
const pressableRequest = pressableDuringRouteTransition ? {
|
|
46724
|
+
[PRESSABLE_ATTRIBUTE]: ""
|
|
46725
|
+
} : null;
|
|
45666
46726
|
const visualSelector = ".navi_button_content";
|
|
45667
46727
|
useAccentColorAttributes(ref, null, {
|
|
45668
46728
|
elementSelector: visualSelector
|
|
@@ -45691,6 +46751,8 @@ const ButtonUI = props => {
|
|
|
45691
46751
|
rel: innerRel,
|
|
45692
46752
|
replace: undefined,
|
|
45693
46753
|
...replaceRequest,
|
|
46754
|
+
pressableDuringRouteTransition: undefined,
|
|
46755
|
+
...pressableRequest,
|
|
45694
46756
|
// Respond with the JS prop value directly so callers (e.g. resolveCommandValue)
|
|
45695
46757
|
// get the original type instead of the DOM-coerced string (e.g. "[object Object]").
|
|
45696
46758
|
onnavi_get_value: e => {
|
|
@@ -45914,6 +46976,13 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
|
|
|
45914
46976
|
* `--navi-nav-to` command — by TAKING THE PLACE of the current history entry
|
|
45915
46977
|
* rather than stacking on it: what `<Link replace>` says, for a press drawn
|
|
45916
46978
|
* as a button.
|
|
46979
|
+
* @param {boolean} [pressableDuringRouteTransition] Keep answering presses
|
|
46980
|
+
* while a route transition plays: what a movement photographs goes deaf to
|
|
46981
|
+
* the pointer for its whole length, and the door that opened the page — a
|
|
46982
|
+
* button in a bar the two states share, standing exactly where its picture is
|
|
46983
|
+
* drawn — must still close it. What `<Link pressableDuringRouteTransition>`
|
|
46984
|
+
* says, for the other half of a toggle. Only for a control that does not
|
|
46985
|
+
* travel with the pages.
|
|
45917
46986
|
* @param {Function} [action] On a button with an `href` or a `route`, the
|
|
45918
46987
|
* same order as a Link's: it runs on the press, before the navigation, and
|
|
45919
46988
|
* the navigation does not wait for it (see Link's `action`).
|
|
@@ -54700,800 +55769,206 @@ const useInputGroup = (ref) => {
|
|
|
54700
55769
|
active.selectionEnd === active.value.length);
|
|
54701
55770
|
if (!atEnd) {
|
|
54702
55771
|
return;
|
|
54703
|
-
}
|
|
54704
|
-
const inputs = getInputs();
|
|
54705
|
-
const idx = inputs.indexOf(active);
|
|
54706
|
-
if (idx === -1) {
|
|
54707
|
-
debugFocus(
|
|
54708
|
-
e,
|
|
54709
|
-
"InputGroup ArrowRight on non group input → do nothing",
|
|
54710
|
-
);
|
|
54711
|
-
return;
|
|
54712
|
-
}
|
|
54713
|
-
if (idx === inputs.length - 1) {
|
|
54714
|
-
debugFocus(
|
|
54715
|
-
e,
|
|
54716
|
-
"InputGroup ArrowRight at end of last input → do nothing",
|
|
54717
|
-
);
|
|
54718
|
-
return;
|
|
54719
|
-
}
|
|
54720
|
-
|
|
54721
|
-
debugFocus(
|
|
54722
|
-
e,
|
|
54723
|
-
"InputGroup ArrowRight at end of input[%d] → focus input[%d]",
|
|
54724
|
-
idx,
|
|
54725
|
-
idx + 1,
|
|
54726
|
-
);
|
|
54727
|
-
e.preventDefault();
|
|
54728
|
-
focusInput(inputs[idx + 1]);
|
|
54729
|
-
return;
|
|
54730
|
-
}
|
|
54731
|
-
const allSelected =
|
|
54732
|
-
active.selectionStart === 0 &&
|
|
54733
|
-
active.selectionEnd === active.value.length;
|
|
54734
|
-
const atStart =
|
|
54735
|
-
allSelected ||
|
|
54736
|
-
(active.selectionStart === 0 && active.selectionEnd === 0);
|
|
54737
|
-
if (!atStart) {
|
|
54738
|
-
return;
|
|
54739
|
-
}
|
|
54740
|
-
const inputs = getInputs();
|
|
54741
|
-
const idx = inputs.indexOf(active);
|
|
54742
|
-
if (idx === 0) {
|
|
54743
|
-
return;
|
|
54744
|
-
}
|
|
54745
|
-
debugFocus(
|
|
54746
|
-
e,
|
|
54747
|
-
"InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
|
|
54748
|
-
idx,
|
|
54749
|
-
idx - 1,
|
|
54750
|
-
);
|
|
54751
|
-
e.preventDefault();
|
|
54752
|
-
focusInput(inputs[idx - 1]);
|
|
54753
|
-
};
|
|
54754
|
-
|
|
54755
|
-
const handleNaviInputFull = (e) => {
|
|
54756
|
-
if (!e.detail.event?.isTrusted) {
|
|
54757
|
-
// Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
|
|
54758
|
-
return;
|
|
54759
|
-
}
|
|
54760
|
-
const input = e.detail.event.currentTarget;
|
|
54761
|
-
if (!el.contains(input)) {
|
|
54762
|
-
return;
|
|
54763
|
-
}
|
|
54764
|
-
const inputs = getInputs();
|
|
54765
|
-
const idx = inputs.indexOf(input);
|
|
54766
|
-
if (idx === -1) {
|
|
54767
|
-
return;
|
|
54768
|
-
}
|
|
54769
|
-
if (idx === inputs.length - 1) {
|
|
54770
|
-
return;
|
|
54771
|
-
}
|
|
54772
|
-
const nextInput = inputs[idx + 1];
|
|
54773
|
-
debugFocus(
|
|
54774
|
-
e,
|
|
54775
|
-
"InputGroup navi_input_full on input -> move to next input",
|
|
54776
|
-
input,
|
|
54777
|
-
nextInput,
|
|
54778
|
-
);
|
|
54779
|
-
e.preventDefault();
|
|
54780
|
-
focusInput(nextInput);
|
|
54781
|
-
};
|
|
54782
|
-
|
|
54783
|
-
// const handlePaste = (e) => {
|
|
54784
|
-
// const active = document.activeElement;
|
|
54785
|
-
// if (!isTextInputElement(active) || !el.contains(active)) {
|
|
54786
|
-
// return;
|
|
54787
|
-
// }
|
|
54788
|
-
// const inputs = getInputs();
|
|
54789
|
-
// const startIdx = inputs.indexOf(active);
|
|
54790
|
-
// if (startIdx === -1) {
|
|
54791
|
-
// return;
|
|
54792
|
-
// }
|
|
54793
|
-
// const pastedText = e.clipboardData?.getData("text") ?? "";
|
|
54794
|
-
// if (!pastedText) {
|
|
54795
|
-
// return;
|
|
54796
|
-
// }
|
|
54797
|
-
// // Only intercept when the pasted text contains at least one separator
|
|
54798
|
-
// // from the inputs starting at the focused position.
|
|
54799
|
-
// const remainingInputs = inputs.slice(startIdx);
|
|
54800
|
-
// const hasSeparatorMatch = remainingInputs.some(
|
|
54801
|
-
// (input) =>
|
|
54802
|
-
// input.dataset.separator &&
|
|
54803
|
-
// pastedText.includes(input.dataset.separator),
|
|
54804
|
-
// );
|
|
54805
|
-
// if (!hasSeparatorMatch) {
|
|
54806
|
-
// return;
|
|
54807
|
-
// }
|
|
54808
|
-
// e.preventDefault();
|
|
54809
|
-
// let remaining = pastedText;
|
|
54810
|
-
// let lastFilledIdx = startIdx;
|
|
54811
|
-
// for (let i = 0; i < remainingInputs.length; i++) {
|
|
54812
|
-
// const input = remainingInputs[i];
|
|
54813
|
-
// const separator = input.dataset.separator;
|
|
54814
|
-
// let part;
|
|
54815
|
-
// if (separator && remaining.includes(separator)) {
|
|
54816
|
-
// const sepIdx = remaining.indexOf(separator);
|
|
54817
|
-
// part = remaining.slice(0, sepIdx);
|
|
54818
|
-
// remaining = remaining.slice(sepIdx + separator.length);
|
|
54819
|
-
// } else {
|
|
54820
|
-
// part = remaining;
|
|
54821
|
-
// remaining = "";
|
|
54822
|
-
// }
|
|
54823
|
-
// requestSubPaste(input, part, e);
|
|
54824
|
-
// lastFilledIdx = startIdx + i;
|
|
54825
|
-
// if (remaining === "") {
|
|
54826
|
-
// break;
|
|
54827
|
-
// }
|
|
54828
|
-
// }
|
|
54829
|
-
// focusInput(inputs[lastFilledIdx]);
|
|
54830
|
-
// };
|
|
54831
|
-
|
|
54832
|
-
el.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
54833
|
-
el.addEventListener("navi_input_full", handleNaviInputFull);
|
|
54834
|
-
// el.addEventListener("paste", handlePaste, { capture: true });
|
|
54835
|
-
return () => {
|
|
54836
|
-
el.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
54837
|
-
el.removeEventListener("navi_input_full", handleNaviInputFull);
|
|
54838
|
-
// el.removeEventListener("paste", handlePaste, { capture: true });
|
|
54839
|
-
};
|
|
54840
|
-
}, [debugFocus]);
|
|
54841
|
-
};
|
|
54842
|
-
|
|
54843
|
-
// const requestSubPaste = (input, value, event) => {
|
|
54844
|
-
// dispatchRequestInteraction(input, {
|
|
54845
|
-
// event,
|
|
54846
|
-
// name: "subpaste",
|
|
54847
|
-
// allowed: () => {
|
|
54848
|
-
// dispatchRequestSetUIState(input, value, { event });
|
|
54849
|
-
// },
|
|
54850
|
-
// });
|
|
54851
|
-
// };
|
|
54852
|
-
|
|
54853
|
-
const isTextInputElement = (el) => {
|
|
54854
|
-
if (!el) {
|
|
54855
|
-
return false;
|
|
54856
|
-
}
|
|
54857
|
-
if (el.tagName === "TEXTAREA") {
|
|
54858
|
-
return true;
|
|
54859
|
-
}
|
|
54860
|
-
if (el.tagName !== "INPUT") {
|
|
54861
|
-
return false;
|
|
54862
|
-
}
|
|
54863
|
-
const type = el.type || "text";
|
|
54864
|
-
return (
|
|
54865
|
-
type === "text" ||
|
|
54866
|
-
type === "search" ||
|
|
54867
|
-
type === "url" ||
|
|
54868
|
-
type === "tel" ||
|
|
54869
|
-
type === "email" ||
|
|
54870
|
-
type === "password" ||
|
|
54871
|
-
type === "number"
|
|
54872
|
-
);
|
|
54873
|
-
};
|
|
54874
|
-
|
|
54875
|
-
// How many lines the thing around a component gives it, when that thing caps
|
|
54876
|
-
// its own height and cannot cap what it holds.
|
|
54877
|
-
//
|
|
54878
|
-
// A <Picker> is the case it exists for: its value is clamped with maxLines,
|
|
54879
|
-
// which is CSS line-clamp — it counts line boxes of inline text and has no idea
|
|
54880
|
-
// what a wrapped flex row is. So a <BadgeList> rendered as a picker's ui reads
|
|
54881
|
-
// the number from here and caps its own rows to it, and the picker turns its
|
|
54882
|
-
// own clamp off (see .navi_picker_value:has(.navi_badge_list) in picker.jsx).
|
|
54883
|
-
const MaxLinesContext = createContext(undefined);
|
|
54884
|
-
|
|
54885
|
-
// When a component render a prop that can be anything (js value of preact element)
|
|
54886
|
-
// make sure it cannot throw during render by converting it to a string if it's not a valid preact element or a primitive value
|
|
54887
|
-
const renderSafe = (value) => {
|
|
54888
|
-
if (
|
|
54889
|
-
value === null ||
|
|
54890
|
-
value === undefined ||
|
|
54891
|
-
typeof value === "string" ||
|
|
54892
|
-
typeof value === "number" ||
|
|
54893
|
-
typeof value === "boolean"
|
|
54894
|
-
) {
|
|
54895
|
-
return value;
|
|
54896
|
-
}
|
|
54897
|
-
|
|
54898
|
-
if (isValidElement(value)) {
|
|
54899
|
-
return value;
|
|
54900
|
-
}
|
|
54901
|
-
|
|
54902
|
-
return String(value);
|
|
54903
|
-
};
|
|
54904
|
-
|
|
54905
|
-
// How long a popup waits before handing the focus to a field, when giving it
|
|
54906
|
-
// is what raises the on-screen keyboard.
|
|
54907
|
-
//
|
|
54908
|
-
// The focus is normally given as early as possible. But a popup places itself
|
|
54909
|
-
// against the viewport, and on a phone the keyboard takes a third of that
|
|
54910
|
-
// viewport away the moment a field receives focus — so the two landing in the
|
|
54911
|
-
// same tick means the popup is still arriving when the room under it changes,
|
|
54912
|
-
// and it re-places itself mid-entrance. Waiting lets it settle first, and the
|
|
54913
|
-
// keyboard then shrinks a box that has stopped moving.
|
|
54914
|
-
//
|
|
54915
|
-
// Long enough to outlast an entrance transition rather than merely reaching
|
|
54916
|
-
// the next frame: what has to be over is the popup MOVING, not one paint of
|
|
54917
|
-
// it.
|
|
54918
|
-
const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
|
|
54919
|
-
|
|
54920
|
-
/**
|
|
54921
|
-
* Owns open/close decision-making for a popup (Dialog or Popover): guards
|
|
54922
|
-
* against duplicate requests and notifies the popup owner's own reactions.
|
|
54923
|
-
*
|
|
54924
|
-
* `controller.openEffect` is implemented by the controlled element (Dialog or
|
|
54925
|
-
* Popover), reassigned on every render so it always closes over the latest
|
|
54926
|
-
* props (scrollCapture, anchor, etc.). It performs whatever DOM side effects
|
|
54927
|
-
* are needed to make the element actually open (`showModal()`/`showPopover()`,
|
|
54928
|
-
* focus transfer, positioning, traps...) and returns its cleanup —
|
|
54929
|
-
* the matching side effects to sync back to closed (`close()`/
|
|
54930
|
-
* `hidePopover()`, releasing traps...). That cleanup is kept private to the
|
|
54931
|
-
* controller (not exposed as a property) and invoked when the popup actually
|
|
54932
|
-
* closes, however that happens.
|
|
54933
|
-
*
|
|
54934
|
-
* Dialog/Popover also call `openController.requestClose(e, { isCancel })` for
|
|
54935
|
-
* their own internal triggers (backdrop click, Escape).
|
|
54936
|
-
*
|
|
54937
|
-
* `openHandler` is the popup owner's own business logic, passed once to
|
|
54938
|
-
* `createOpenController`. Its return value is `{ onRequestClose, onClose }`,
|
|
54939
|
-
* in the spirit of CloseWatcher
|
|
54940
|
-
* (https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher) but with
|
|
54941
|
-
* clearer naming than its cancel/close pair:
|
|
54942
|
-
* - `onRequestClose(e)`: about to close — call `e.preventDefault()` to stay
|
|
54943
|
-
* open. Validation lives here.
|
|
54944
|
-
* - `onClose(e)`: actually closing, not preventable — final reactions live here.
|
|
54945
|
-
*
|
|
54946
|
-
* The controller exposes matching action methods:
|
|
54947
|
-
* - `open()`: requests opening — calls the caller's `onOpen` (see below), then
|
|
54948
|
-
* `mountContent`/`openEffect`, then `openHandler`.
|
|
54949
|
-
* - `requestClose()`: requests closing — calls `onRequestClose` then `onClose`,
|
|
54950
|
-
* stopping after the first if denied. The popup may choose to stay open,
|
|
54951
|
-
* which is what a `false` return says (`true`: closed, or closed already).
|
|
54952
|
-
* - `close()`: closes for real — calls only `onClose`, skipping
|
|
54953
|
-
* `onRequestClose` entirely. Used when there really is no choice (e.g. the
|
|
54954
|
-
* popup unmounting).
|
|
54955
|
-
*/
|
|
54956
|
-
const createOpenController = (
|
|
54957
|
-
openHandler,
|
|
54958
|
-
{ debugInteraction } = {},
|
|
54959
|
-
) => {
|
|
54960
|
-
let closeHandlers = null; // { onRequestClose, onClose } returned by openHandler
|
|
54961
|
-
let openEffectCleanup = null; // function returned by openEffect, undoes its DOM side effects
|
|
54962
|
-
let focusedAtClose = null; // what held the focus when the close was decided, see performClose
|
|
54963
|
-
|
|
54964
|
-
// Set true while we're waiting to see whether the click that follows a
|
|
54965
|
-
// mousedown-close will land back on whatever would reopen us — see
|
|
54966
|
-
// armSuppressNextOpenRequest below.
|
|
54967
|
-
let suppressNextOpenRequest = false;
|
|
54968
|
-
let disarmSuppressNextOpenRequest = null;
|
|
54969
|
-
|
|
54970
|
-
// When the popup closes because of a mousedown (e.g. clicking the
|
|
54971
|
-
// backdrop), the browser still dispatches the matching "click" afterward.
|
|
54972
|
-
// If that click lands back on the element that triggers open() (e.g. the
|
|
54973
|
-
// picker button), it would immediately reopen the popup. We cannot
|
|
54974
|
-
// preventDefault/stopPropagation the mousedown to stop that — the browser
|
|
54975
|
-
// dispatches the click regardless.
|
|
54976
|
-
//
|
|
54977
|
-
// Instead: arm a capture-phase "click" listener on document. Capture fires
|
|
54978
|
-
// before the click reaches its target, so by the time any bubble-phase
|
|
54979
|
-
// click handler (e.g. the trigger button's onClick, which calls
|
|
54980
|
-
// controller.open()) runs, `suppressNextOpenRequest` is already true and
|
|
54981
|
-
// open() ignores the request — no need to know *which* element triggers
|
|
54982
|
-
// it. A bubble-phase listener (runs after everything else, once the click
|
|
54983
|
-
// reaches document) clears the flag if nothing consumed it, meaning this
|
|
54984
|
-
// click never resulted in an open() call. A timeout is a last-resort safety
|
|
54985
|
-
// net in case the click never reaches document at all (e.g. some ancestor
|
|
54986
|
-
// called stopPropagation()) — a *task*, never a microtask: a microtask
|
|
54987
|
-
// checkpoint runs between two listeners of the same trusted event dispatch,
|
|
54988
|
-
// so it would clear the flag before the bubble-phase handler this is meant
|
|
54989
|
-
// to block ever runs, which is precisely the case it exists for.
|
|
54990
|
-
const armSuppressNextOpenRequest = () => {
|
|
54991
|
-
disarmSuppressNextOpenRequest?.();
|
|
54992
|
-
let safetyTimeout = null;
|
|
54993
|
-
const onCaptureClick = () => {
|
|
54994
|
-
document.removeEventListener("click", onCaptureClick, {
|
|
54995
|
-
capture: true,
|
|
54996
|
-
});
|
|
54997
|
-
suppressNextOpenRequest = true;
|
|
54998
|
-
document.addEventListener("click", onBubbleClick);
|
|
54999
|
-
safetyTimeout = setTimeout(() => {
|
|
55000
|
-
suppressNextOpenRequest = false;
|
|
55001
|
-
});
|
|
55002
|
-
};
|
|
55003
|
-
const onBubbleClick = () => {
|
|
55004
|
-
document.removeEventListener("click", onBubbleClick);
|
|
55005
|
-
clearTimeout(safetyTimeout);
|
|
55006
|
-
suppressNextOpenRequest = false;
|
|
55007
|
-
};
|
|
55008
|
-
disarmSuppressNextOpenRequest = () => {
|
|
55009
|
-
clearTimeout(safetyTimeout);
|
|
55010
|
-
document.removeEventListener("click", onCaptureClick, {
|
|
55011
|
-
capture: true,
|
|
55012
|
-
});
|
|
55013
|
-
document.removeEventListener("click", onBubbleClick);
|
|
55014
|
-
};
|
|
55015
|
-
document.addEventListener("click", onCaptureClick, { capture: true });
|
|
55016
|
-
};
|
|
55017
|
-
|
|
55018
|
-
const performClose = (closeEvent) => {
|
|
55019
|
-
controller.opened = false;
|
|
55020
|
-
// Read before any close effect touches the DOM: closing a native <dialog>
|
|
55021
|
-
// hands the focus back to whatever held it at showModal() time, so by the
|
|
55022
|
-
// time the close cleanup runs, the popup's content has already lost the
|
|
55023
|
-
// focus and could not be remembered for the next open.
|
|
55024
|
-
focusedAtClose = document.activeElement;
|
|
55025
|
-
|
|
55026
|
-
prevent_reopen: {
|
|
55027
|
-
const mousedownEvent = findEvent(closeEvent, "mousedown");
|
|
55028
|
-
if (mousedownEvent) {
|
|
55029
|
-
debugInteraction(
|
|
55030
|
-
closeEvent,
|
|
55031
|
-
`closed by mousedown -> ignore next click`,
|
|
55032
|
-
);
|
|
55033
|
-
armSuppressNextOpenRequest();
|
|
55034
|
-
break prevent_reopen;
|
|
55035
|
-
}
|
|
55036
|
-
|
|
55037
|
-
// The keyboard counterpart of the mousedown case above: a key press that
|
|
55038
|
-
// closes the popup and then goes on to activate the trigger, reopening it
|
|
55039
|
-
// on the spot. Space and Enter both get there, but not the same way and
|
|
55040
|
-
// not always — preventing the key unconditionally would eat presses that
|
|
55041
|
-
// were never going to activate anything (a space typed in a field, an
|
|
55042
|
-
// Enter the popup's own handler already consumed), so each is verified
|
|
55043
|
-
// before being prevented.
|
|
55044
|
-
|
|
55045
|
-
// Space: pressed on the trigger itself, which still has focus (closing
|
|
55046
|
-
// does not move it away from an element outside the popup). The browser
|
|
55047
|
-
// turns that press into a click on keyup, and that click lands back on
|
|
55048
|
-
// the trigger. Asked of the browser's own default action rather than
|
|
55049
|
-
// guessed from the tag name: a space that scrolls, or types into a field
|
|
55050
|
-
// inside the popup, has no activation to prevent and preventing it would
|
|
55051
|
-
// swallow the scroll / the character.
|
|
55052
|
-
const spaceKeyEvent = findEvent(
|
|
55053
|
-
closeEvent,
|
|
55054
|
-
(e) => e.type === "keydown" && e.key === " ",
|
|
55055
|
-
);
|
|
55056
|
-
if (
|
|
55057
|
-
spaceKeyEvent &&
|
|
55058
|
-
getKeyboardEventDefaultAction(spaceKeyEvent) === "activate"
|
|
55059
|
-
) {
|
|
55060
|
-
debugInteraction(
|
|
55061
|
-
closeEvent,
|
|
55062
|
-
`closed by space on <${spaceKeyEvent.target.tagName.toLowerCase()}> -> prevent the click it would produce (space.preventDefault())`,
|
|
55063
|
-
);
|
|
55064
|
-
// The browser won't dispatch the click, and our "space_to_open" sees
|
|
55065
|
-
// defaultPrevented too so it won't try to open the picker either.
|
|
55066
|
-
spaceKeyEvent.preventDefault();
|
|
55067
|
-
break prevent_reopen;
|
|
55068
|
-
}
|
|
55069
|
-
|
|
55070
|
-
// Enter: pressed inside the popup (its own submit button, or implicit
|
|
55071
|
-
// submission from a field it contains). The popup closes synchronously
|
|
55072
|
-
// and focus is restored to the trigger, so the activation the browser
|
|
55073
|
-
// still owes this press is delivered to the trigger instead.
|
|
55074
|
-
//
|
|
55075
|
-
// Verified on two counts: the press still owes an activation (the
|
|
55076
|
-
// browser's default action for it is one — "activate" on a submit button,
|
|
55077
|
-
// "form_submit" on a field — and nothing has consumed it yet), and it came
|
|
55078
|
-
// from inside the popup. An Enter from outside is not this case at all.
|
|
55079
|
-
const enterKeyEvent = findEvent(
|
|
55080
|
-
closeEvent,
|
|
55081
|
-
(e) => e.type === "keydown" && e.key === "Enter",
|
|
55082
|
-
);
|
|
55083
|
-
if (
|
|
55084
|
-
enterKeyEvent &&
|
|
55085
|
-
!enterKeyEvent.defaultPrevented &&
|
|
55086
|
-
ENTER_ACTIVATING_DEFAULT_ACTION_SET.has(
|
|
55087
|
-
getKeyboardEventDefaultAction(enterKeyEvent),
|
|
55088
|
-
) &&
|
|
55089
|
-
isInsideOpenPopup(enterKeyEvent.target)
|
|
55090
|
-
) {
|
|
55091
|
-
debugInteraction(
|
|
55092
|
-
closeEvent,
|
|
55093
|
-
`closed by enter from inside the popup -> prevent the activation it would deliver to the trigger (enter.preventDefault())`,
|
|
55094
|
-
);
|
|
55095
|
-
enterKeyEvent.preventDefault();
|
|
55096
|
-
break prevent_reopen;
|
|
55097
|
-
}
|
|
55098
|
-
}
|
|
55099
|
-
|
|
55100
|
-
// Sync the DOM closed first (releasing the focus trap) — only then run
|
|
55101
|
-
// the owner's own reaction (onClose may restore focus to an element
|
|
55102
|
-
// outside the popup, which the focus trap would otherwise fight while
|
|
55103
|
-
// still active).
|
|
55104
|
-
openEffectCleanup?.(closeEvent);
|
|
55105
|
-
openEffectCleanup = null;
|
|
55106
|
-
closeHandlers?.onClose?.(closeEvent);
|
|
55107
|
-
closeHandlers = null;
|
|
55108
|
-
// Last: the close effects above are what starts the exit transition the
|
|
55109
|
-
// content must outlive (see popup_content_mount.js).
|
|
55110
|
-
controller.unmountContent?.();
|
|
55111
|
-
controller.onOpenedChange?.(false, closeEvent);
|
|
55112
|
-
};
|
|
55113
|
-
const controller = {
|
|
55114
|
-
opened: false,
|
|
55115
|
-
openEffect: null,
|
|
55116
|
-
// Set by the controlled element (see popup_content_mount.js) when its
|
|
55117
|
-
// content is still waiting for a first open to be built. Called below,
|
|
55118
|
-
// before openEffect, so the popup measures and positions the real thing.
|
|
55119
|
-
mountContent: null,
|
|
55120
|
-
// The caller's own `onOpen`, set by Dialog/Popover from their props on
|
|
55121
|
-
// every render (like openEffect). Called BEFORE mountContent, so whatever
|
|
55122
|
-
// it decides — which record this dialog is opening on — is already true by
|
|
55123
|
-
// the time the content is built, positioned and shown. That order is the
|
|
55124
|
-
// whole point: learning it afterwards means the content mounted on the
|
|
55125
|
-
// previous subject first.
|
|
55126
|
-
onOpen: null,
|
|
55127
|
-
// The counterpart, set only when the popup was told to throw its content
|
|
55128
|
-
// away on close (`mount="while-opened"`). Called from performClose above.
|
|
55129
|
-
unmountContent: null,
|
|
55130
|
-
// Told whenever `opened` actually changes, whatever asked for it — an
|
|
55131
|
-
// interaction, a command, a prop — with the event that asked. What lets a
|
|
55132
|
-
// `signal` prop reflect the popup's real state, and a `navState` prop write
|
|
55133
|
-
// it into the history entry (see useOpenPropsEffectOnOpenController);
|
|
55134
|
-
// called once the open/close has fully happened rather than mid-sequence.
|
|
55135
|
-
onOpenedChange: null,
|
|
55136
|
-
open: (e, detail) => {
|
|
55137
|
-
if (controller.opened || !controller.openEffect) {
|
|
55772
|
+
}
|
|
55773
|
+
const inputs = getInputs();
|
|
55774
|
+
const idx = inputs.indexOf(active);
|
|
55775
|
+
if (idx === -1) {
|
|
55776
|
+
debugFocus(
|
|
55777
|
+
e,
|
|
55778
|
+
"InputGroup ArrowRight on non group input → do nothing",
|
|
55779
|
+
);
|
|
55780
|
+
return;
|
|
55781
|
+
}
|
|
55782
|
+
if (idx === inputs.length - 1) {
|
|
55783
|
+
debugFocus(
|
|
55784
|
+
e,
|
|
55785
|
+
"InputGroup ArrowRight at end of last input → do nothing",
|
|
55786
|
+
);
|
|
55787
|
+
return;
|
|
55788
|
+
}
|
|
55789
|
+
|
|
55790
|
+
debugFocus(
|
|
55791
|
+
e,
|
|
55792
|
+
"InputGroup ArrowRight at end of input[%d] → focus input[%d]",
|
|
55793
|
+
idx,
|
|
55794
|
+
idx + 1,
|
|
55795
|
+
);
|
|
55796
|
+
e.preventDefault();
|
|
55797
|
+
focusInput(inputs[idx + 1]);
|
|
55138
55798
|
return;
|
|
55139
55799
|
}
|
|
55140
|
-
|
|
55141
|
-
|
|
55800
|
+
const allSelected =
|
|
55801
|
+
active.selectionStart === 0 &&
|
|
55802
|
+
active.selectionEnd === active.value.length;
|
|
55803
|
+
const atStart =
|
|
55804
|
+
allSelected ||
|
|
55805
|
+
(active.selectionStart === 0 && active.selectionEnd === 0);
|
|
55806
|
+
if (!atStart) {
|
|
55142
55807
|
return;
|
|
55143
55808
|
}
|
|
55144
|
-
const
|
|
55145
|
-
|
|
55146
|
-
|
|
55147
|
-
|
|
55148
|
-
|
|
55149
|
-
|
|
55150
|
-
|
|
55151
|
-
|
|
55152
|
-
|
|
55153
|
-
|
|
55809
|
+
const inputs = getInputs();
|
|
55810
|
+
const idx = inputs.indexOf(active);
|
|
55811
|
+
if (idx === 0) {
|
|
55812
|
+
return;
|
|
55813
|
+
}
|
|
55814
|
+
debugFocus(
|
|
55815
|
+
e,
|
|
55816
|
+
"InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
|
|
55817
|
+
idx,
|
|
55818
|
+
idx - 1,
|
|
55154
55819
|
);
|
|
55155
|
-
|
|
55156
|
-
|
|
55157
|
-
|
|
55158
|
-
// element a mousedown/click landed on. `e` itself is usually the raw
|
|
55159
|
-
// native event: its own `.detail` is a number (click count) on a
|
|
55160
|
-
// MouseEvent, so `e.detail.eventChain` is always undefined and the
|
|
55161
|
-
// mousedown/click branches below never matched — silently falling
|
|
55162
|
-
// back to `document.activeElement`, which is often `document.body`
|
|
55163
|
-
// once mousedown.preventDefault() has kept focus from landing
|
|
55164
|
-
// anywhere yet.
|
|
55820
|
+
e.preventDefault();
|
|
55821
|
+
focusInput(inputs[idx - 1]);
|
|
55822
|
+
};
|
|
55165
55823
|
|
|
55166
|
-
|
|
55167
|
-
|
|
55168
|
-
//
|
|
55169
|
-
|
|
55170
|
-
// whichever of its two inputs was just used — the open event still
|
|
55171
|
-
// remembers which one it was. An opening with no pointer in it at
|
|
55172
|
-
// all (a keyboard shortcut, defaultOpen, an app calling open()) is
|
|
55173
|
-
// not one either.
|
|
55174
|
-
// - the target: focusing a button raises nothing, so there is nothing
|
|
55175
|
-
// to wait for and the focus stays immediate. Only a field the
|
|
55176
|
-
// keyboard comes up for is worth delaying — which is why the
|
|
55177
|
-
// decision is taken on the resolved target, inside transferFocus.
|
|
55178
|
-
const openedByTouch = Boolean(
|
|
55179
|
-
findEvent(requestOpenEvent, isTouchDrivenEvent),
|
|
55180
|
-
);
|
|
55181
|
-
const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
|
|
55182
|
-
getDelay: (target) =>
|
|
55183
|
-
openedByTouch && isEditableTarget(target)
|
|
55184
|
-
? FOCUS_DELAY_ON_KEYBOARD_MS
|
|
55185
|
-
: 0,
|
|
55186
|
-
});
|
|
55187
|
-
return (closeEvent) => {
|
|
55188
|
-
// Closed before the delay was up: the focus was never given, so it
|
|
55189
|
-
// must not be given now — to a field inside a popup on its way out,
|
|
55190
|
-
// raising the keyboard as it goes.
|
|
55191
|
-
cancelPendingFocus?.();
|
|
55192
|
-
markAutofocusRestoreOnClose(el, closeEvent, focusedAtClose);
|
|
55193
|
-
const focusoutEvent = findEvent(closeEvent, "focusout");
|
|
55194
|
-
if (focusoutEvent) {
|
|
55195
|
-
debugInteraction(
|
|
55196
|
-
closeEvent,
|
|
55197
|
-
`closed by focusout -> let focus go away`,
|
|
55198
|
-
);
|
|
55199
|
-
} else {
|
|
55200
|
-
const mousedownEvent = findEvent(closeEvent, "mousedown");
|
|
55201
|
-
if (mousedownEvent) {
|
|
55202
|
-
debugInteraction(
|
|
55203
|
-
closeEvent,
|
|
55204
|
-
"closed by mousedown -> prevent browser focus (mousedown.preventDefault())",
|
|
55205
|
-
);
|
|
55206
|
-
mousedownEvent.preventDefault();
|
|
55207
|
-
}
|
|
55208
|
-
focusTransfer.restoreFocus();
|
|
55209
|
-
}
|
|
55210
|
-
};
|
|
55211
|
-
};
|
|
55212
|
-
// Before mountContent, which builds the content, and before openEffect,
|
|
55213
|
-
// which shows it: what the popup opens ON has to be known before either
|
|
55214
|
-
// (see `onOpen` above).
|
|
55215
|
-
controller.onOpen?.(requestOpenEvent);
|
|
55216
|
-
// After prepareFocusTransfer, which has to record what held the focus
|
|
55217
|
-
// before anything inside the popup can claim it, and before openEffect,
|
|
55218
|
-
// which measures the popup to place it.
|
|
55219
|
-
controller.mountContent?.();
|
|
55220
|
-
// Only now — after the content has been built, before openEffect shows
|
|
55221
|
-
// it. Dialog/Popover recompute aria-expanded and navi-hidden from this
|
|
55222
|
-
// flag on every render, and mountContent above renders synchronously:
|
|
55223
|
-
// flipping it any earlier commits an already-open DOM (aria-expanded
|
|
55224
|
-
// "true", navi-hidden gone) before openEffect has run a single
|
|
55225
|
-
// statement, so the "closed" frame it pins to transition from is in
|
|
55226
|
-
// fact the open one and the entrance animation has nothing to play.
|
|
55227
|
-
// It also gives the content it just built the opening it is documented
|
|
55228
|
-
// to observe — mounted while the popup reads as closed, told it opened
|
|
55229
|
-
// right after (see popup_content_mount.js and
|
|
55230
|
-
// use_displayed_layout_effect.js).
|
|
55231
|
-
controller.opened = true;
|
|
55232
|
-
const openEffectReturnValue =
|
|
55233
|
-
controller.openEffect(requestOpenEvent) || null;
|
|
55234
|
-
openEffectCleanup = (closeEvent) => {
|
|
55235
|
-
openEffectReturnValue?.(closeEvent);
|
|
55236
|
-
};
|
|
55237
|
-
closeHandlers = openHandler(requestOpenEvent) || null;
|
|
55238
|
-
controller.onOpenedChange?.(true, requestOpenEvent);
|
|
55239
|
-
},
|
|
55240
|
-
requestClose: (
|
|
55241
|
-
e = new CustomEvent("programmatic", { detail: {} }),
|
|
55242
|
-
detail,
|
|
55243
|
-
) => {
|
|
55244
|
-
if (!controller.opened) {
|
|
55245
|
-
return true;
|
|
55824
|
+
const handleNaviInputFull = (e) => {
|
|
55825
|
+
if (!e.detail.event?.isTrusted) {
|
|
55826
|
+
// Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
|
|
55827
|
+
return;
|
|
55246
55828
|
}
|
|
55247
|
-
const
|
|
55248
|
-
|
|
55249
|
-
|
|
55250
|
-
});
|
|
55251
|
-
chainEvent(requestCloseEvent, e);
|
|
55252
|
-
closeHandlers?.onRequestClose?.(requestCloseEvent);
|
|
55253
|
-
if (requestCloseEvent.defaultPrevented) {
|
|
55254
|
-
// The native <dialog> "cancel" event (Escape key) closes the dialog
|
|
55255
|
-
// by default; prevent that default so denial actually keeps it open.
|
|
55256
|
-
const nativeCancelEvent = findEvent(requestCloseEvent, "cancel");
|
|
55257
|
-
if (nativeCancelEvent) {
|
|
55258
|
-
nativeCancelEvent.preventDefault();
|
|
55259
|
-
}
|
|
55260
|
-
return false;
|
|
55829
|
+
const input = e.detail.event.currentTarget;
|
|
55830
|
+
if (!el.contains(input)) {
|
|
55831
|
+
return;
|
|
55261
55832
|
}
|
|
55262
|
-
|
|
55263
|
-
|
|
55264
|
-
|
|
55265
|
-
close: (e = new CustomEvent("programmatic", { detail: {} }), detail) => {
|
|
55266
|
-
if (!controller.opened) {
|
|
55833
|
+
const inputs = getInputs();
|
|
55834
|
+
const idx = inputs.indexOf(input);
|
|
55835
|
+
if (idx === -1) {
|
|
55267
55836
|
return;
|
|
55268
55837
|
}
|
|
55269
|
-
|
|
55270
|
-
|
|
55271
|
-
}
|
|
55272
|
-
|
|
55273
|
-
|
|
55274
|
-
|
|
55275
|
-
|
|
55276
|
-
|
|
55277
|
-
|
|
55278
|
-
|
|
55279
|
-
|
|
55280
|
-
|
|
55281
|
-
|
|
55282
|
-
const isInsideOpenPopup = (element) => {
|
|
55283
|
-
if (!element || element.nodeType !== 1) {
|
|
55284
|
-
return false;
|
|
55285
|
-
}
|
|
55286
|
-
return Boolean(element.closest("dialog[open], [popover]:popover-open"));
|
|
55287
|
-
};
|
|
55838
|
+
if (idx === inputs.length - 1) {
|
|
55839
|
+
return;
|
|
55840
|
+
}
|
|
55841
|
+
const nextInput = inputs[idx + 1];
|
|
55842
|
+
debugFocus(
|
|
55843
|
+
e,
|
|
55844
|
+
"InputGroup navi_input_full on input -> move to next input",
|
|
55845
|
+
input,
|
|
55846
|
+
nextInput,
|
|
55847
|
+
);
|
|
55848
|
+
e.preventDefault();
|
|
55849
|
+
focusInput(nextInput);
|
|
55850
|
+
};
|
|
55288
55851
|
|
|
55289
|
-
//
|
|
55290
|
-
//
|
|
55291
|
-
// (
|
|
55292
|
-
//
|
|
55293
|
-
|
|
55294
|
-
|
|
55295
|
-
|
|
55296
|
-
|
|
55852
|
+
// const handlePaste = (e) => {
|
|
55853
|
+
// const active = document.activeElement;
|
|
55854
|
+
// if (!isTextInputElement(active) || !el.contains(active)) {
|
|
55855
|
+
// return;
|
|
55856
|
+
// }
|
|
55857
|
+
// const inputs = getInputs();
|
|
55858
|
+
// const startIdx = inputs.indexOf(active);
|
|
55859
|
+
// if (startIdx === -1) {
|
|
55860
|
+
// return;
|
|
55861
|
+
// }
|
|
55862
|
+
// const pastedText = e.clipboardData?.getData("text") ?? "";
|
|
55863
|
+
// if (!pastedText) {
|
|
55864
|
+
// return;
|
|
55865
|
+
// }
|
|
55866
|
+
// // Only intercept when the pasted text contains at least one separator
|
|
55867
|
+
// // from the inputs starting at the focused position.
|
|
55868
|
+
// const remainingInputs = inputs.slice(startIdx);
|
|
55869
|
+
// const hasSeparatorMatch = remainingInputs.some(
|
|
55870
|
+
// (input) =>
|
|
55871
|
+
// input.dataset.separator &&
|
|
55872
|
+
// pastedText.includes(input.dataset.separator),
|
|
55873
|
+
// );
|
|
55874
|
+
// if (!hasSeparatorMatch) {
|
|
55875
|
+
// return;
|
|
55876
|
+
// }
|
|
55877
|
+
// e.preventDefault();
|
|
55878
|
+
// let remaining = pastedText;
|
|
55879
|
+
// let lastFilledIdx = startIdx;
|
|
55880
|
+
// for (let i = 0; i < remainingInputs.length; i++) {
|
|
55881
|
+
// const input = remainingInputs[i];
|
|
55882
|
+
// const separator = input.dataset.separator;
|
|
55883
|
+
// let part;
|
|
55884
|
+
// if (separator && remaining.includes(separator)) {
|
|
55885
|
+
// const sepIdx = remaining.indexOf(separator);
|
|
55886
|
+
// part = remaining.slice(0, sepIdx);
|
|
55887
|
+
// remaining = remaining.slice(sepIdx + separator.length);
|
|
55888
|
+
// } else {
|
|
55889
|
+
// part = remaining;
|
|
55890
|
+
// remaining = "";
|
|
55891
|
+
// }
|
|
55892
|
+
// requestSubPaste(input, part, e);
|
|
55893
|
+
// lastFilledIdx = startIdx + i;
|
|
55894
|
+
// if (remaining === "") {
|
|
55895
|
+
// break;
|
|
55896
|
+
// }
|
|
55897
|
+
// }
|
|
55898
|
+
// focusInput(inputs[lastFilledIdx]);
|
|
55899
|
+
// };
|
|
55297
55900
|
|
|
55298
|
-
|
|
55299
|
-
|
|
55300
|
-
//
|
|
55301
|
-
// openController.openEffect on every render.
|
|
55302
|
-
const useOpenController = (openHandler) => {
|
|
55303
|
-
const debugInteraction = useDebugInteraction();
|
|
55304
|
-
const stableOpenHandler = useStableCallback(openHandler);
|
|
55305
|
-
const controllerRef = useRef(null);
|
|
55306
|
-
if (!controllerRef.current) {
|
|
55307
|
-
controllerRef.current = createOpenController(stableOpenHandler, {
|
|
55308
|
-
debugInteraction,
|
|
55309
|
-
});
|
|
55310
|
-
}
|
|
55311
|
-
// Unmount safety net: if Dialog/Popover unmounts while still open (parent
|
|
55312
|
-
// removes it from the tree without going through requestClose()), there is
|
|
55313
|
-
// no choice to leave open — close it for real.
|
|
55314
|
-
useLayoutEffect(() => {
|
|
55901
|
+
el.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
55902
|
+
el.addEventListener("navi_input_full", handleNaviInputFull);
|
|
55903
|
+
// el.addEventListener("paste", handlePaste, { capture: true });
|
|
55315
55904
|
return () => {
|
|
55316
|
-
|
|
55905
|
+
el.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
55906
|
+
el.removeEventListener("navi_input_full", handleNaviInputFull);
|
|
55907
|
+
// el.removeEventListener("paste", handlePaste, { capture: true });
|
|
55317
55908
|
};
|
|
55318
|
-
}, []);
|
|
55319
|
-
return controllerRef.current;
|
|
55909
|
+
}, [debugFocus]);
|
|
55320
55910
|
};
|
|
55321
55911
|
|
|
55322
|
-
//
|
|
55323
|
-
//
|
|
55324
|
-
//
|
|
55325
|
-
//
|
|
55326
|
-
//
|
|
55327
|
-
//
|
|
55328
|
-
//
|
|
55329
|
-
//
|
|
55330
|
-
//
|
|
55331
|
-
// then simply running them in *reverse* of their registration order fixes
|
|
55332
|
-
// this — no need to compare DOM positions: since effects already fire
|
|
55333
|
-
// child-first, tree-wide, for *any* ancestor/descendant pair the descendant
|
|
55334
|
-
// is always queued before the ancestor, regardless of what else is in the
|
|
55335
|
-
// tree, so reversing the whole batch always puts every ancestor before its
|
|
55336
|
-
// own descendants. Works for any nesting depth for the same reason. Two
|
|
55337
|
-
// unrelated (sibling) popups both mounting open also get reordered
|
|
55338
|
-
// relative to each other, but there's no meaningful "correct" order between
|
|
55339
|
-
// those anyway.
|
|
55340
|
-
let pendingMountOpens = [];
|
|
55341
|
-
let mountOpenFlushScheduled = false;
|
|
55342
|
-
const scheduleMountOpen = (run) => {
|
|
55343
|
-
pendingMountOpens.push(run);
|
|
55344
|
-
if (mountOpenFlushScheduled) {
|
|
55345
|
-
return;
|
|
55346
|
-
}
|
|
55347
|
-
mountOpenFlushScheduled = true;
|
|
55348
|
-
queueMicrotask(() => {
|
|
55349
|
-
const entries = pendingMountOpens;
|
|
55350
|
-
pendingMountOpens = [];
|
|
55351
|
-
mountOpenFlushScheduled = false;
|
|
55352
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
55353
|
-
entries[i]();
|
|
55354
|
-
}
|
|
55355
|
-
});
|
|
55356
|
-
};
|
|
55912
|
+
// const requestSubPaste = (input, value, event) => {
|
|
55913
|
+
// dispatchRequestInteraction(input, {
|
|
55914
|
+
// event,
|
|
55915
|
+
// name: "subpaste",
|
|
55916
|
+
// allowed: () => {
|
|
55917
|
+
// dispatchRequestSetUIState(input, value, { event });
|
|
55918
|
+
// },
|
|
55919
|
+
// });
|
|
55920
|
+
// };
|
|
55357
55921
|
|
|
55358
|
-
|
|
55359
|
-
|
|
55360
|
-
|
|
55361
|
-
// `true` takes the popup's own id — a popup a `--navi-open` command can name is
|
|
55362
|
-
// a popup that already has a stable one, and that id is what identifies its
|
|
55363
|
-
// open state too.
|
|
55364
|
-
const NO_NAV_STATE = { id: undefined, type: "replace" };
|
|
55365
|
-
const resolveNavStateProp = (navState, popupId) => {
|
|
55366
|
-
if (!navState) {
|
|
55367
|
-
return NO_NAV_STATE;
|
|
55922
|
+
const isTextInputElement = (el) => {
|
|
55923
|
+
if (!el) {
|
|
55924
|
+
return false;
|
|
55368
55925
|
}
|
|
55369
|
-
if (
|
|
55370
|
-
return
|
|
55926
|
+
if (el.tagName === "TEXTAREA") {
|
|
55927
|
+
return true;
|
|
55371
55928
|
}
|
|
55372
|
-
if (
|
|
55373
|
-
return
|
|
55929
|
+
if (el.tagName !== "INPUT") {
|
|
55930
|
+
return false;
|
|
55374
55931
|
}
|
|
55375
|
-
|
|
55932
|
+
const type = el.type || "text";
|
|
55933
|
+
return (
|
|
55934
|
+
type === "text" ||
|
|
55935
|
+
type === "search" ||
|
|
55936
|
+
type === "url" ||
|
|
55937
|
+
type === "tel" ||
|
|
55938
|
+
type === "email" ||
|
|
55939
|
+
type === "password" ||
|
|
55940
|
+
type === "number"
|
|
55941
|
+
);
|
|
55376
55942
|
};
|
|
55377
55943
|
|
|
55378
|
-
|
|
55379
|
-
|
|
55380
|
-
|
|
55381
|
-
|
|
55382
|
-
|
|
55383
|
-
|
|
55384
|
-
|
|
55385
|
-
|
|
55386
|
-
|
|
55387
|
-
*
|
|
55388
|
-
* @param {{ open: (e: Event, detail?: object) => void, requestClose: (e: Event, detail?: object) => void, opened: boolean }} openController
|
|
55389
|
-
* @param {{ id?: string, open?: boolean|"interaction", defaultOpen?: boolean|"interaction", signal?: import("@preact/signals").Signal<boolean>, navState?: boolean|string|{id?: string, type?: "push"|"replace"} }} props
|
|
55390
|
-
*/
|
|
55391
|
-
const useOpenPropsEffectOnOpenController = (openController, props) => {
|
|
55392
|
-
const { signal, defaultOpen, navState } = props;
|
|
55393
|
-
const { id: navStateId, type: navStateType } = resolveNavStateProp(
|
|
55394
|
-
navState,
|
|
55395
|
-
props.id,
|
|
55396
|
-
);
|
|
55397
|
-
// Called unconditionally (it answers with no-ops for an absent id), like
|
|
55398
|
-
// every other hook here.
|
|
55399
|
-
const [navStateValue, enterNavState, leaveNavState] = useNavState(
|
|
55400
|
-
navStateId,
|
|
55401
|
-
{ type: navStateType },
|
|
55402
|
-
);
|
|
55403
|
-
// What the caller holds, however they hold it: the history entry when there
|
|
55404
|
-
// is a `navState`, an `open` they re-render themselves, or a `signal` this
|
|
55405
|
-
// hook also writes (see onOpenedChange below). Reading .value during render
|
|
55406
|
-
// is what subscribes the popup to a signal; reading the document state is
|
|
55407
|
-
// what subscribes it to the history entry, back button included.
|
|
55408
|
-
const open = navStateId
|
|
55409
|
-
? Boolean(navStateValue)
|
|
55410
|
-
: signal
|
|
55411
|
-
? signal.value
|
|
55412
|
-
: props.open;
|
|
55413
|
-
// Assigned on every render, like openEffect, so it always closes over the
|
|
55414
|
-
// latest prop: a popup that opens or closes on its own (Escape, backdrop, a
|
|
55415
|
-
// --navi-close command) writes what happened where the caller keeps it, so
|
|
55416
|
-
// whoever holds it always reads where the popup is.
|
|
55417
|
-
openController.onOpenedChange =
|
|
55418
|
-
navStateId || signal
|
|
55419
|
-
? (opened, event) => {
|
|
55420
|
-
if (navStateId) {
|
|
55421
|
-
if (opened) {
|
|
55422
|
-
enterNavState();
|
|
55423
|
-
} else {
|
|
55424
|
-
// Under type "push" a cancel goes back rather than rewriting the
|
|
55425
|
-
// entry, so everything else written to the url while the popup
|
|
55426
|
-
// was open goes back with it (see useNavState's own leave()).
|
|
55427
|
-
leaveNavState({ isBack: Boolean(event?.detail?.isCancel) });
|
|
55428
|
-
}
|
|
55429
|
-
}
|
|
55430
|
-
if (signal) {
|
|
55431
|
-
signal.value = opened;
|
|
55432
|
-
}
|
|
55433
|
-
}
|
|
55434
|
-
: null;
|
|
55435
|
-
// Tracks whether the effect below has ever run before — only the very
|
|
55436
|
-
// first run gets the "mount already open" treatment (`open` truthy from
|
|
55437
|
-
// the start, or the uncontrolled, mount-only `defaultOpen`); every
|
|
55438
|
-
// subsequent `open` change is a real, later toggle and should animate
|
|
55439
|
-
// normally like any other interactive open/close.
|
|
55440
|
-
const isFirstRunRef = useRef(true);
|
|
55944
|
+
// How many lines the thing around a component gives it, when that thing caps
|
|
55945
|
+
// its own height and cannot cap what it holds.
|
|
55946
|
+
//
|
|
55947
|
+
// A <Picker> is the case it exists for: its value is clamped with maxLines,
|
|
55948
|
+
// which is CSS line-clamp — it counts line boxes of inline text and has no idea
|
|
55949
|
+
// what a wrapped flex row is. So a <BadgeList> rendered as a picker's ui reads
|
|
55950
|
+
// the number from here and caps its own rows to it, and the picker turns its
|
|
55951
|
+
// own clamp off (see .navi_picker_value:has(.navi_badge_list) in picker.jsx).
|
|
55952
|
+
const MaxLinesContext = createContext(undefined);
|
|
55441
55953
|
|
|
55442
|
-
|
|
55443
|
-
|
|
55444
|
-
|
|
55954
|
+
// When a component render a prop that can be anything (js value of preact element)
|
|
55955
|
+
// make sure it cannot throw during render by converting it to a string if it's not a valid preact element or a primitive value
|
|
55956
|
+
const renderSafe = (value) => {
|
|
55957
|
+
if (
|
|
55958
|
+
value === null ||
|
|
55959
|
+
value === undefined ||
|
|
55960
|
+
typeof value === "string" ||
|
|
55961
|
+
typeof value === "number" ||
|
|
55962
|
+
typeof value === "boolean"
|
|
55963
|
+
) {
|
|
55964
|
+
return value;
|
|
55965
|
+
}
|
|
55445
55966
|
|
|
55446
|
-
|
|
55447
|
-
|
|
55448
|
-
|
|
55449
|
-
// Whether this popup being open is something that just happened, or
|
|
55450
|
-
// something that was already true when the page appeared. "interaction"
|
|
55451
|
-
// says the mount IS the opening — the popup exists because the user
|
|
55452
|
-
// just asked for it — so the entrance plays like any other open. Any
|
|
55453
|
-
// other truthy value means it was simply already open: nothing was ever
|
|
55454
|
-
// shown as "closed" for the user to see it transition away from, so the
|
|
55455
|
-
// entrance is skipped (`silent`, see popover.jsx's own openEffect).
|
|
55456
|
-
//
|
|
55457
|
-
// Deferred + batched (see scheduleMountOpen above) rather than called
|
|
55458
|
-
// directly, so nested popups that both mount already-open end up
|
|
55459
|
-
// stacked ancestor-first instead of Preact's own child-first effect
|
|
55460
|
-
// order.
|
|
55461
|
-
scheduleMountOpen(() =>
|
|
55462
|
-
openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
|
|
55463
|
-
silent: mountOpenReason !== "interaction",
|
|
55464
|
-
}),
|
|
55465
|
-
);
|
|
55466
|
-
}
|
|
55467
|
-
return;
|
|
55468
|
-
}
|
|
55967
|
+
if (isValidElement(value)) {
|
|
55968
|
+
return value;
|
|
55969
|
+
}
|
|
55469
55970
|
|
|
55470
|
-
|
|
55471
|
-
return;
|
|
55472
|
-
}
|
|
55473
|
-
// Skip when the controller is already in the desired state.
|
|
55474
|
-
// openController.opened tracks actual open/close (updated by onopen/onclose,
|
|
55475
|
-
// not by renders) so it is the authoritative check against feedback loops.
|
|
55476
|
-
if (open === openController.opened) {
|
|
55477
|
-
return;
|
|
55478
|
-
}
|
|
55479
|
-
if (open) {
|
|
55480
|
-
openController.open(new CustomEvent("open_by_prop", { detail: {} }));
|
|
55481
|
-
} else {
|
|
55482
|
-
openController.requestClose(
|
|
55483
|
-
new CustomEvent("close_by_prop", { detail: {} }),
|
|
55484
|
-
{ isCancel: true },
|
|
55485
|
-
);
|
|
55486
|
-
}
|
|
55487
|
-
// The request can be refused (a busy form denying the close): the popup
|
|
55488
|
-
// then stays where it was, and whoever holds the open state is told so —
|
|
55489
|
-
// otherwise it would keep saying "closed" about a popup still open.
|
|
55490
|
-
if (signal) {
|
|
55491
|
-
signal.value = openController.opened;
|
|
55492
|
-
}
|
|
55493
|
-
if (navStateId && openController.opened) {
|
|
55494
|
-
enterNavState();
|
|
55495
|
-
}
|
|
55496
|
-
}, [open]);
|
|
55971
|
+
return String(value);
|
|
55497
55972
|
};
|
|
55498
55973
|
|
|
55499
55974
|
/**
|
|
@@ -57039,10 +57514,10 @@ const useDialogProps = props => {
|
|
|
57039
57514
|
// the latest prop. Called by openController.open() before the content is
|
|
57040
57515
|
// built: what this popup opens ON is known before anything reads it.
|
|
57041
57516
|
openController.onOpen = onOpen || null;
|
|
57042
|
-
const
|
|
57043
|
-
children: childrenProp,
|
|
57517
|
+
const contentMounted = usePopupContentMount(openController, props.ref, {
|
|
57044
57518
|
mount
|
|
57045
57519
|
});
|
|
57520
|
+
const children = contentMounted ? childrenProp : null;
|
|
57046
57521
|
const isModal = layer === "top";
|
|
57047
57522
|
const ref = props.ref;
|
|
57048
57523
|
const expandY = Boolean(expand) || Boolean(expandYProp);
|
|
@@ -58484,10 +58959,10 @@ const usePopoverProps = props => {
|
|
|
58484
58959
|
// the latest prop. Called by openController.open() before the content is
|
|
58485
58960
|
// built: what this popup opens ON is known before anything reads it.
|
|
58486
58961
|
openController.onOpen = onOpen || null;
|
|
58487
|
-
const
|
|
58488
|
-
children: childrenProp,
|
|
58962
|
+
const contentMounted = usePopupContentMount(openController, props.ref, {
|
|
58489
58963
|
mount
|
|
58490
58964
|
});
|
|
58965
|
+
const children = contentMounted ? childrenProp : null;
|
|
58491
58966
|
const isTopLayer = layer === "top";
|
|
58492
58967
|
const ref = props.ref;
|
|
58493
58968
|
const backdropRef = useRef();
|
|
@@ -80749,6 +81224,14 @@ const css = /* css */`.navi_side_panel {
|
|
|
80749
81224
|
* required at all for a purely command-driven panel (an `id` plus a
|
|
80750
81225
|
* `<Button command="--navi-toggle" commandFor={id}>` elsewhere, same as
|
|
80751
81226
|
* `Dialog`/`Popover` themselves — see either's own doc).
|
|
81227
|
+
* @param {boolean|string|{id?: string, type?: "push"|"replace"}} [props.navState] -
|
|
81228
|
+
* Keeps the open state in the history entry, so a screen left and come back
|
|
81229
|
+
* to finds its panel as it was: `true` uses the panel's own `id`, a string
|
|
81230
|
+
* names the key, `{ id, type }` chooses between rewriting the entry
|
|
81231
|
+
* ("replace", the default) and pushing one ("push"), where closing goes
|
|
81232
|
+
* back — which is what makes the phone's back gesture close the panel.
|
|
81233
|
+
* Forwarded as-is to `Popup`; source of truth, so it excludes `open` and
|
|
81234
|
+
* `signal` (see `Dialog`/`Popover`'s own `navState`).
|
|
80752
81235
|
* @param {(event: Event) => void} [props.onClose] - Called when the panel
|
|
80753
81236
|
* actually closes (see `Dialog`/`Popover`'s own `onClose`).
|
|
80754
81237
|
* @param {"left"|"right"|"top"|"bottom"} [props.side="right"] - Which
|
|
@@ -80830,8 +81313,16 @@ const SidePanel = ({
|
|
|
80830
81313
|
import.meta.css = [css, "@jsenv/navi/src/layout/side_panel.jsx"];
|
|
80831
81314
|
const onSwipePointerDown = swipeToClose ? createSwipeToClose(side) : null;
|
|
80832
81315
|
return jsx(Popup, {
|
|
80833
|
-
mode: mode
|
|
80834
|
-
|
|
81316
|
+
mode: mode
|
|
81317
|
+
// Spread rather than written: the collision warning between `signal` and
|
|
81318
|
+
// `open` asks whether the prop is THERE, not what it holds, so a panel
|
|
81319
|
+
// handed a signal and nothing else would be told off for an `open` only
|
|
81320
|
+
// this line ever put on it.
|
|
81321
|
+
,
|
|
81322
|
+
|
|
81323
|
+
...(open === undefined ? null : {
|
|
81324
|
+
open
|
|
81325
|
+
}),
|
|
80835
81326
|
signal: signal,
|
|
80836
81327
|
defaultOpen: defaultOpen,
|
|
80837
81328
|
onClose: onClose,
|