@jsenv/navi 0.29.164 → 0.29.165
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 +1030 -1019
- package/dist/jsenv_navi.js.map +7 -7
- 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";
|
|
@@ -43900,6 +43900,619 @@ const DetailsFieldContent = ({
|
|
|
43900
43900
|
});
|
|
43901
43901
|
};
|
|
43902
43902
|
|
|
43903
|
+
// How long a popup waits before handing the focus to a field, when giving it
|
|
43904
|
+
// is what raises the on-screen keyboard.
|
|
43905
|
+
//
|
|
43906
|
+
// The focus is normally given as early as possible. But a popup places itself
|
|
43907
|
+
// against the viewport, and on a phone the keyboard takes a third of that
|
|
43908
|
+
// viewport away the moment a field receives focus — so the two landing in the
|
|
43909
|
+
// same tick means the popup is still arriving when the room under it changes,
|
|
43910
|
+
// and it re-places itself mid-entrance. Waiting lets it settle first, and the
|
|
43911
|
+
// keyboard then shrinks a box that has stopped moving.
|
|
43912
|
+
//
|
|
43913
|
+
// Long enough to outlast an entrance transition rather than merely reaching
|
|
43914
|
+
// the next frame: what has to be over is the popup MOVING, not one paint of
|
|
43915
|
+
// it.
|
|
43916
|
+
const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
|
|
43917
|
+
|
|
43918
|
+
/**
|
|
43919
|
+
* Owns open/close decision-making for a popup (Dialog or Popover): guards
|
|
43920
|
+
* against duplicate requests and notifies the popup owner's own reactions.
|
|
43921
|
+
*
|
|
43922
|
+
* `controller.openEffect` is implemented by the controlled element (Dialog or
|
|
43923
|
+
* Popover), reassigned on every render so it always closes over the latest
|
|
43924
|
+
* props (scrollCapture, anchor, etc.). It performs whatever DOM side effects
|
|
43925
|
+
* are needed to make the element actually open (`showModal()`/`showPopover()`,
|
|
43926
|
+
* focus transfer, positioning, traps...) and returns its cleanup —
|
|
43927
|
+
* the matching side effects to sync back to closed (`close()`/
|
|
43928
|
+
* `hidePopover()`, releasing traps...). That cleanup is kept private to the
|
|
43929
|
+
* controller (not exposed as a property) and invoked when the popup actually
|
|
43930
|
+
* closes, however that happens.
|
|
43931
|
+
*
|
|
43932
|
+
* Dialog/Popover also call `openController.requestClose(e, { isCancel })` for
|
|
43933
|
+
* their own internal triggers (backdrop click, Escape).
|
|
43934
|
+
*
|
|
43935
|
+
* `openHandler` is the popup owner's own business logic, passed once to
|
|
43936
|
+
* `createOpenController`. Its return value is `{ onRequestClose, onClose }`,
|
|
43937
|
+
* in the spirit of CloseWatcher
|
|
43938
|
+
* (https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher) but with
|
|
43939
|
+
* clearer naming than its cancel/close pair:
|
|
43940
|
+
* - `onRequestClose(e)`: about to close — call `e.preventDefault()` to stay
|
|
43941
|
+
* open. Validation lives here.
|
|
43942
|
+
* - `onClose(e)`: actually closing, not preventable — final reactions live here.
|
|
43943
|
+
*
|
|
43944
|
+
* The controller exposes matching action methods:
|
|
43945
|
+
* - `open()`: requests opening — calls the caller's `onOpen` (see below), then
|
|
43946
|
+
* `mountContent`/`openEffect`, then `openHandler`.
|
|
43947
|
+
* - `requestClose()`: requests closing — calls `onRequestClose` then `onClose`,
|
|
43948
|
+
* stopping after the first if denied. The popup may choose to stay open,
|
|
43949
|
+
* which is what a `false` return says (`true`: closed, or closed already).
|
|
43950
|
+
* - `close()`: closes for real — calls only `onClose`, skipping
|
|
43951
|
+
* `onRequestClose` entirely. Used when there really is no choice (e.g. the
|
|
43952
|
+
* popup unmounting).
|
|
43953
|
+
*/
|
|
43954
|
+
const createOpenController = (
|
|
43955
|
+
openHandler,
|
|
43956
|
+
{ debugInteraction } = {},
|
|
43957
|
+
) => {
|
|
43958
|
+
let closeHandlers = null; // { onRequestClose, onClose } returned by openHandler
|
|
43959
|
+
let openEffectCleanup = null; // function returned by openEffect, undoes its DOM side effects
|
|
43960
|
+
let focusedAtClose = null; // what held the focus when the close was decided, see performClose
|
|
43961
|
+
|
|
43962
|
+
// Set true while we're waiting to see whether the click that follows a
|
|
43963
|
+
// mousedown-close will land back on whatever would reopen us — see
|
|
43964
|
+
// armSuppressNextOpenRequest below.
|
|
43965
|
+
let suppressNextOpenRequest = false;
|
|
43966
|
+
let disarmSuppressNextOpenRequest = null;
|
|
43967
|
+
|
|
43968
|
+
// When the popup closes because of a mousedown (e.g. clicking the
|
|
43969
|
+
// backdrop), the browser still dispatches the matching "click" afterward.
|
|
43970
|
+
// If that click lands back on the element that triggers open() (e.g. the
|
|
43971
|
+
// picker button), it would immediately reopen the popup. We cannot
|
|
43972
|
+
// preventDefault/stopPropagation the mousedown to stop that — the browser
|
|
43973
|
+
// dispatches the click regardless.
|
|
43974
|
+
//
|
|
43975
|
+
// Instead: arm a capture-phase "click" listener on document. Capture fires
|
|
43976
|
+
// before the click reaches its target, so by the time any bubble-phase
|
|
43977
|
+
// click handler (e.g. the trigger button's onClick, which calls
|
|
43978
|
+
// controller.open()) runs, `suppressNextOpenRequest` is already true and
|
|
43979
|
+
// open() ignores the request — no need to know *which* element triggers
|
|
43980
|
+
// it. A bubble-phase listener (runs after everything else, once the click
|
|
43981
|
+
// reaches document) clears the flag if nothing consumed it, meaning this
|
|
43982
|
+
// click never resulted in an open() call. A timeout is a last-resort safety
|
|
43983
|
+
// net in case the click never reaches document at all (e.g. some ancestor
|
|
43984
|
+
// called stopPropagation()) — a *task*, never a microtask: a microtask
|
|
43985
|
+
// checkpoint runs between two listeners of the same trusted event dispatch,
|
|
43986
|
+
// so it would clear the flag before the bubble-phase handler this is meant
|
|
43987
|
+
// to block ever runs, which is precisely the case it exists for.
|
|
43988
|
+
const armSuppressNextOpenRequest = () => {
|
|
43989
|
+
disarmSuppressNextOpenRequest?.();
|
|
43990
|
+
let safetyTimeout = null;
|
|
43991
|
+
const onCaptureClick = () => {
|
|
43992
|
+
document.removeEventListener("click", onCaptureClick, {
|
|
43993
|
+
capture: true,
|
|
43994
|
+
});
|
|
43995
|
+
suppressNextOpenRequest = true;
|
|
43996
|
+
document.addEventListener("click", onBubbleClick);
|
|
43997
|
+
safetyTimeout = setTimeout(() => {
|
|
43998
|
+
suppressNextOpenRequest = false;
|
|
43999
|
+
});
|
|
44000
|
+
};
|
|
44001
|
+
const onBubbleClick = () => {
|
|
44002
|
+
document.removeEventListener("click", onBubbleClick);
|
|
44003
|
+
clearTimeout(safetyTimeout);
|
|
44004
|
+
suppressNextOpenRequest = false;
|
|
44005
|
+
};
|
|
44006
|
+
disarmSuppressNextOpenRequest = () => {
|
|
44007
|
+
clearTimeout(safetyTimeout);
|
|
44008
|
+
document.removeEventListener("click", onCaptureClick, {
|
|
44009
|
+
capture: true,
|
|
44010
|
+
});
|
|
44011
|
+
document.removeEventListener("click", onBubbleClick);
|
|
44012
|
+
};
|
|
44013
|
+
document.addEventListener("click", onCaptureClick, { capture: true });
|
|
44014
|
+
};
|
|
44015
|
+
|
|
44016
|
+
const performClose = (closeEvent) => {
|
|
44017
|
+
controller.opened = false;
|
|
44018
|
+
// Read before any close effect touches the DOM: closing a native <dialog>
|
|
44019
|
+
// hands the focus back to whatever held it at showModal() time, so by the
|
|
44020
|
+
// time the close cleanup runs, the popup's content has already lost the
|
|
44021
|
+
// focus and could not be remembered for the next open.
|
|
44022
|
+
focusedAtClose = document.activeElement;
|
|
44023
|
+
|
|
44024
|
+
prevent_reopen: {
|
|
44025
|
+
const mousedownEvent = findEvent(closeEvent, "mousedown");
|
|
44026
|
+
if (mousedownEvent) {
|
|
44027
|
+
debugInteraction(
|
|
44028
|
+
closeEvent,
|
|
44029
|
+
`closed by mousedown -> ignore next click`,
|
|
44030
|
+
);
|
|
44031
|
+
armSuppressNextOpenRequest();
|
|
44032
|
+
break prevent_reopen;
|
|
44033
|
+
}
|
|
44034
|
+
|
|
44035
|
+
// The keyboard counterpart of the mousedown case above: a key press that
|
|
44036
|
+
// closes the popup and then goes on to activate the trigger, reopening it
|
|
44037
|
+
// on the spot. Space and Enter both get there, but not the same way and
|
|
44038
|
+
// not always — preventing the key unconditionally would eat presses that
|
|
44039
|
+
// were never going to activate anything (a space typed in a field, an
|
|
44040
|
+
// Enter the popup's own handler already consumed), so each is verified
|
|
44041
|
+
// before being prevented.
|
|
44042
|
+
|
|
44043
|
+
// Space: pressed on the trigger itself, which still has focus (closing
|
|
44044
|
+
// does not move it away from an element outside the popup). The browser
|
|
44045
|
+
// turns that press into a click on keyup, and that click lands back on
|
|
44046
|
+
// the trigger. Asked of the browser's own default action rather than
|
|
44047
|
+
// guessed from the tag name: a space that scrolls, or types into a field
|
|
44048
|
+
// inside the popup, has no activation to prevent and preventing it would
|
|
44049
|
+
// swallow the scroll / the character.
|
|
44050
|
+
const spaceKeyEvent = findEvent(
|
|
44051
|
+
closeEvent,
|
|
44052
|
+
(e) => e.type === "keydown" && e.key === " ",
|
|
44053
|
+
);
|
|
44054
|
+
if (
|
|
44055
|
+
spaceKeyEvent &&
|
|
44056
|
+
getKeyboardEventDefaultAction(spaceKeyEvent) === "activate"
|
|
44057
|
+
) {
|
|
44058
|
+
debugInteraction(
|
|
44059
|
+
closeEvent,
|
|
44060
|
+
`closed by space on <${spaceKeyEvent.target.tagName.toLowerCase()}> -> prevent the click it would produce (space.preventDefault())`,
|
|
44061
|
+
);
|
|
44062
|
+
// The browser won't dispatch the click, and our "space_to_open" sees
|
|
44063
|
+
// defaultPrevented too so it won't try to open the picker either.
|
|
44064
|
+
spaceKeyEvent.preventDefault();
|
|
44065
|
+
break prevent_reopen;
|
|
44066
|
+
}
|
|
44067
|
+
|
|
44068
|
+
// Enter: pressed inside the popup (its own submit button, or implicit
|
|
44069
|
+
// submission from a field it contains). The popup closes synchronously
|
|
44070
|
+
// and focus is restored to the trigger, so the activation the browser
|
|
44071
|
+
// still owes this press is delivered to the trigger instead.
|
|
44072
|
+
//
|
|
44073
|
+
// Verified on two counts: the press still owes an activation (the
|
|
44074
|
+
// browser's default action for it is one — "activate" on a submit button,
|
|
44075
|
+
// "form_submit" on a field — and nothing has consumed it yet), and it came
|
|
44076
|
+
// from inside the popup. An Enter from outside is not this case at all.
|
|
44077
|
+
const enterKeyEvent = findEvent(
|
|
44078
|
+
closeEvent,
|
|
44079
|
+
(e) => e.type === "keydown" && e.key === "Enter",
|
|
44080
|
+
);
|
|
44081
|
+
if (
|
|
44082
|
+
enterKeyEvent &&
|
|
44083
|
+
!enterKeyEvent.defaultPrevented &&
|
|
44084
|
+
ENTER_ACTIVATING_DEFAULT_ACTION_SET.has(
|
|
44085
|
+
getKeyboardEventDefaultAction(enterKeyEvent),
|
|
44086
|
+
) &&
|
|
44087
|
+
isInsideOpenPopup(enterKeyEvent.target)
|
|
44088
|
+
) {
|
|
44089
|
+
debugInteraction(
|
|
44090
|
+
closeEvent,
|
|
44091
|
+
`closed by enter from inside the popup -> prevent the activation it would deliver to the trigger (enter.preventDefault())`,
|
|
44092
|
+
);
|
|
44093
|
+
enterKeyEvent.preventDefault();
|
|
44094
|
+
break prevent_reopen;
|
|
44095
|
+
}
|
|
44096
|
+
}
|
|
44097
|
+
|
|
44098
|
+
// Sync the DOM closed first (releasing the focus trap) — only then run
|
|
44099
|
+
// the owner's own reaction (onClose may restore focus to an element
|
|
44100
|
+
// outside the popup, which the focus trap would otherwise fight while
|
|
44101
|
+
// still active).
|
|
44102
|
+
openEffectCleanup?.(closeEvent);
|
|
44103
|
+
openEffectCleanup = null;
|
|
44104
|
+
closeHandlers?.onClose?.(closeEvent);
|
|
44105
|
+
closeHandlers = null;
|
|
44106
|
+
// Last: the close effects above are what starts the exit transition the
|
|
44107
|
+
// content must outlive (see popup_content_mount.js).
|
|
44108
|
+
controller.unmountContent?.();
|
|
44109
|
+
controller.onOpenedChange?.(false, closeEvent);
|
|
44110
|
+
};
|
|
44111
|
+
const controller = {
|
|
44112
|
+
opened: false,
|
|
44113
|
+
openEffect: null,
|
|
44114
|
+
// Set by the controlled element (see popup_content_mount.js) when its
|
|
44115
|
+
// content is still waiting for a first open to be built. Called below,
|
|
44116
|
+
// before openEffect, so the popup measures and positions the real thing.
|
|
44117
|
+
mountContent: null,
|
|
44118
|
+
// The caller's own `onOpen`, set by Dialog/Popover from their props on
|
|
44119
|
+
// every render (like openEffect). Called BEFORE mountContent, so whatever
|
|
44120
|
+
// it decides — which record this dialog is opening on — is already true by
|
|
44121
|
+
// the time the content is built, positioned and shown. That order is the
|
|
44122
|
+
// whole point: learning it afterwards means the content mounted on the
|
|
44123
|
+
// previous subject first.
|
|
44124
|
+
onOpen: null,
|
|
44125
|
+
// The counterpart, set only when the popup was told to throw its content
|
|
44126
|
+
// away on close (`mount="while-opened"`). Called from performClose above.
|
|
44127
|
+
unmountContent: null,
|
|
44128
|
+
// Told whenever `opened` actually changes, whatever asked for it — an
|
|
44129
|
+
// interaction, a command, a prop — with the event that asked. What lets a
|
|
44130
|
+
// `signal` prop reflect the popup's real state, and a `navState` prop write
|
|
44131
|
+
// it into the history entry (see useOpenPropsEffectOnOpenController);
|
|
44132
|
+
// called once the open/close has fully happened rather than mid-sequence.
|
|
44133
|
+
onOpenedChange: null,
|
|
44134
|
+
open: (e, detail) => {
|
|
44135
|
+
if (controller.opened || !controller.openEffect) {
|
|
44136
|
+
return;
|
|
44137
|
+
}
|
|
44138
|
+
if (suppressNextOpenRequest) {
|
|
44139
|
+
suppressNextOpenRequest = false;
|
|
44140
|
+
return;
|
|
44141
|
+
}
|
|
44142
|
+
const requestOpenEvent = new CustomEvent("navi_request_open", {
|
|
44143
|
+
detail: { event: e, ...detail },
|
|
44144
|
+
cancelable: true,
|
|
44145
|
+
});
|
|
44146
|
+
chainEvent(requestOpenEvent, e);
|
|
44147
|
+
// we prepare focus transfer before actually opening the popover/dialog
|
|
44148
|
+
// because opnening dialog makes browser try to transfer focus (which ends up in document.body for instance)
|
|
44149
|
+
const focusTransfer = prepareFocusTransfer(
|
|
44150
|
+
requestOpenEvent,
|
|
44151
|
+
debugInteraction,
|
|
44152
|
+
);
|
|
44153
|
+
controller.transferFocusOnOpen = (el) => {
|
|
44154
|
+
// requestOpenEvent, not the raw `e` — getFocusedBeforeTransfer needs
|
|
44155
|
+
// e.detail.eventChain (built by chainEvent above) to recover the
|
|
44156
|
+
// element a mousedown/click landed on. `e` itself is usually the raw
|
|
44157
|
+
// native event: its own `.detail` is a number (click count) on a
|
|
44158
|
+
// MouseEvent, so `e.detail.eventChain` is always undefined and the
|
|
44159
|
+
// mousedown/click branches below never matched — silently falling
|
|
44160
|
+
// back to `document.activeElement`, which is often `document.body`
|
|
44161
|
+
// once mousedown.preventDefault() has kept focus from landing
|
|
44162
|
+
// anywhere yet.
|
|
44163
|
+
|
|
44164
|
+
// Two conditions, and both are about THIS opening rather than about
|
|
44165
|
+
// the device:
|
|
44166
|
+
// - the interaction: only a finger raises a virtual keyboard, and a
|
|
44167
|
+
// hybrid tablet answers "coarse" to every device-level signal
|
|
44168
|
+
// whichever of its two inputs was just used — the open event still
|
|
44169
|
+
// remembers which one it was. An opening with no pointer in it at
|
|
44170
|
+
// all (a keyboard shortcut, defaultOpen, an app calling open()) is
|
|
44171
|
+
// not one either.
|
|
44172
|
+
// - the target: focusing a button raises nothing, so there is nothing
|
|
44173
|
+
// to wait for and the focus stays immediate. Only a field the
|
|
44174
|
+
// keyboard comes up for is worth delaying — which is why the
|
|
44175
|
+
// decision is taken on the resolved target, inside transferFocus.
|
|
44176
|
+
const openedByTouch = Boolean(
|
|
44177
|
+
findEvent(requestOpenEvent, isTouchDrivenEvent),
|
|
44178
|
+
);
|
|
44179
|
+
const cancelPendingFocus = focusTransfer.transferFocus(e, el, {
|
|
44180
|
+
getDelay: (target) =>
|
|
44181
|
+
openedByTouch && isEditableTarget(target)
|
|
44182
|
+
? FOCUS_DELAY_ON_KEYBOARD_MS
|
|
44183
|
+
: 0,
|
|
44184
|
+
});
|
|
44185
|
+
return (closeEvent) => {
|
|
44186
|
+
// Closed before the delay was up: the focus was never given, so it
|
|
44187
|
+
// must not be given now — to a field inside a popup on its way out,
|
|
44188
|
+
// raising the keyboard as it goes.
|
|
44189
|
+
cancelPendingFocus?.();
|
|
44190
|
+
markAutofocusRestoreOnClose(el, closeEvent, focusedAtClose);
|
|
44191
|
+
const focusoutEvent = findEvent(closeEvent, "focusout");
|
|
44192
|
+
if (focusoutEvent) {
|
|
44193
|
+
debugInteraction(
|
|
44194
|
+
closeEvent,
|
|
44195
|
+
`closed by focusout -> let focus go away`,
|
|
44196
|
+
);
|
|
44197
|
+
} else {
|
|
44198
|
+
const mousedownEvent = findEvent(closeEvent, "mousedown");
|
|
44199
|
+
if (mousedownEvent) {
|
|
44200
|
+
debugInteraction(
|
|
44201
|
+
closeEvent,
|
|
44202
|
+
"closed by mousedown -> prevent browser focus (mousedown.preventDefault())",
|
|
44203
|
+
);
|
|
44204
|
+
mousedownEvent.preventDefault();
|
|
44205
|
+
}
|
|
44206
|
+
focusTransfer.restoreFocus();
|
|
44207
|
+
}
|
|
44208
|
+
};
|
|
44209
|
+
};
|
|
44210
|
+
// Before mountContent, which builds the content, and before openEffect,
|
|
44211
|
+
// which shows it: what the popup opens ON has to be known before either
|
|
44212
|
+
// (see `onOpen` above).
|
|
44213
|
+
controller.onOpen?.(requestOpenEvent);
|
|
44214
|
+
// After prepareFocusTransfer, which has to record what held the focus
|
|
44215
|
+
// before anything inside the popup can claim it, and before openEffect,
|
|
44216
|
+
// which measures the popup to place it.
|
|
44217
|
+
controller.mountContent?.();
|
|
44218
|
+
// Only now — after the content has been built, before openEffect shows
|
|
44219
|
+
// it. Dialog/Popover recompute aria-expanded and navi-hidden from this
|
|
44220
|
+
// flag on every render, and mountContent above renders synchronously:
|
|
44221
|
+
// flipping it any earlier commits an already-open DOM (aria-expanded
|
|
44222
|
+
// "true", navi-hidden gone) before openEffect has run a single
|
|
44223
|
+
// statement, so the "closed" frame it pins to transition from is in
|
|
44224
|
+
// fact the open one and the entrance animation has nothing to play.
|
|
44225
|
+
// It also gives the content it just built the opening it is documented
|
|
44226
|
+
// to observe — mounted while the popup reads as closed, told it opened
|
|
44227
|
+
// right after (see popup_content_mount.js and
|
|
44228
|
+
// use_displayed_layout_effect.js).
|
|
44229
|
+
controller.opened = true;
|
|
44230
|
+
const openEffectReturnValue =
|
|
44231
|
+
controller.openEffect(requestOpenEvent) || null;
|
|
44232
|
+
openEffectCleanup = (closeEvent) => {
|
|
44233
|
+
openEffectReturnValue?.(closeEvent);
|
|
44234
|
+
};
|
|
44235
|
+
closeHandlers = openHandler(requestOpenEvent) || null;
|
|
44236
|
+
controller.onOpenedChange?.(true, requestOpenEvent);
|
|
44237
|
+
},
|
|
44238
|
+
requestClose: (
|
|
44239
|
+
e = new CustomEvent("programmatic", { detail: {} }),
|
|
44240
|
+
detail,
|
|
44241
|
+
) => {
|
|
44242
|
+
if (!controller.opened) {
|
|
44243
|
+
return true;
|
|
44244
|
+
}
|
|
44245
|
+
const requestCloseEvent = new CustomEvent("navi_request_close", {
|
|
44246
|
+
detail: { event: e, ...detail },
|
|
44247
|
+
cancelable: true,
|
|
44248
|
+
});
|
|
44249
|
+
chainEvent(requestCloseEvent, e);
|
|
44250
|
+
closeHandlers?.onRequestClose?.(requestCloseEvent);
|
|
44251
|
+
if (requestCloseEvent.defaultPrevented) {
|
|
44252
|
+
// The native <dialog> "cancel" event (Escape key) closes the dialog
|
|
44253
|
+
// by default; prevent that default so denial actually keeps it open.
|
|
44254
|
+
const nativeCancelEvent = findEvent(requestCloseEvent, "cancel");
|
|
44255
|
+
if (nativeCancelEvent) {
|
|
44256
|
+
nativeCancelEvent.preventDefault();
|
|
44257
|
+
}
|
|
44258
|
+
return false;
|
|
44259
|
+
}
|
|
44260
|
+
performClose(requestCloseEvent);
|
|
44261
|
+
return true;
|
|
44262
|
+
},
|
|
44263
|
+
close: (e = new CustomEvent("programmatic", { detail: {} }), detail) => {
|
|
44264
|
+
if (!controller.opened) {
|
|
44265
|
+
return;
|
|
44266
|
+
}
|
|
44267
|
+
const closeEvent = new CustomEvent("navi_close", {
|
|
44268
|
+
detail: { event: e, ...detail },
|
|
44269
|
+
});
|
|
44270
|
+
chainEvent(closeEvent, e);
|
|
44271
|
+
// Skips onRequestClose entirely — there is no choice here.
|
|
44272
|
+
performClose(closeEvent);
|
|
44273
|
+
},
|
|
44274
|
+
};
|
|
44275
|
+
return controller;
|
|
44276
|
+
};
|
|
44277
|
+
|
|
44278
|
+
// Inside a popup that is open right now — the popup being closed, in practice,
|
|
44279
|
+
// since that is the one the key press was delivered to.
|
|
44280
|
+
const isInsideOpenPopup = (element) => {
|
|
44281
|
+
if (!element || element.nodeType !== 1) {
|
|
44282
|
+
return false;
|
|
44283
|
+
}
|
|
44284
|
+
return Boolean(element.closest("dialog[open], [popover]:popover-open"));
|
|
44285
|
+
};
|
|
44286
|
+
|
|
44287
|
+
// What Enter is about to do when it is about to activate something: press the
|
|
44288
|
+
// focused control, or submit the form around it. Anything else it can do
|
|
44289
|
+
// (typing a newline, nothing at all) leaves no activation behind to land on the
|
|
44290
|
+
// trigger once focus is restored.
|
|
44291
|
+
const ENTER_ACTIVATING_DEFAULT_ACTION_SET = new Set([
|
|
44292
|
+
"activate",
|
|
44293
|
+
"form_submit",
|
|
44294
|
+
]);
|
|
44295
|
+
|
|
44296
|
+
// Created once per popup instance: openHandler is wrapped in a stable callback
|
|
44297
|
+
// so the controller identity never changes across renders, even though
|
|
44298
|
+
// Dialog/Popover read fresh closures (scrollTrap, etc.) via
|
|
44299
|
+
// openController.openEffect on every render.
|
|
44300
|
+
const useOpenController = (openHandler) => {
|
|
44301
|
+
const debugInteraction = useDebugInteraction();
|
|
44302
|
+
const stableOpenHandler = useStableCallback(openHandler);
|
|
44303
|
+
const controllerRef = useRef(null);
|
|
44304
|
+
if (!controllerRef.current) {
|
|
44305
|
+
controllerRef.current = createOpenController(stableOpenHandler, {
|
|
44306
|
+
debugInteraction,
|
|
44307
|
+
});
|
|
44308
|
+
}
|
|
44309
|
+
// Unmount safety net: if Dialog/Popover unmounts while still open (parent
|
|
44310
|
+
// removes it from the tree without going through requestClose()), there is
|
|
44311
|
+
// no choice to leave open — close it for real.
|
|
44312
|
+
useLayoutEffect(() => {
|
|
44313
|
+
return () => {
|
|
44314
|
+
controllerRef.current.close();
|
|
44315
|
+
};
|
|
44316
|
+
}, []);
|
|
44317
|
+
return controllerRef.current;
|
|
44318
|
+
};
|
|
44319
|
+
|
|
44320
|
+
// Nested popups that both mount already-open (`open`/`defaultOpen`) would
|
|
44321
|
+
// otherwise stack in the wrong order: Preact fires layout effects
|
|
44322
|
+
// child-first on mount, so a nested popup's own mount-open would call
|
|
44323
|
+
// showPopover() before its ancestor's — and the top layer stacks *later*
|
|
44324
|
+
// showPopover() calls above *earlier* ones (see popover.jsx's own openEffect
|
|
44325
|
+
// comment) — leaving the ancestor on top instead of the nested popup, the
|
|
44326
|
+
// opposite of what opening them one at a time (ancestor first, by real user
|
|
44327
|
+
// interaction) would produce. Batching every mount-time silent open queued
|
|
44328
|
+
// during the same commit's layout-effect phase into one microtask flush,
|
|
44329
|
+
// then simply running them in *reverse* of their registration order fixes
|
|
44330
|
+
// this — no need to compare DOM positions: since effects already fire
|
|
44331
|
+
// child-first, tree-wide, for *any* ancestor/descendant pair the descendant
|
|
44332
|
+
// is always queued before the ancestor, regardless of what else is in the
|
|
44333
|
+
// tree, so reversing the whole batch always puts every ancestor before its
|
|
44334
|
+
// own descendants. Works for any nesting depth for the same reason. Two
|
|
44335
|
+
// unrelated (sibling) popups both mounting open also get reordered
|
|
44336
|
+
// relative to each other, but there's no meaningful "correct" order between
|
|
44337
|
+
// those anyway.
|
|
44338
|
+
let pendingMountOpens = [];
|
|
44339
|
+
let mountOpenFlushScheduled = false;
|
|
44340
|
+
const scheduleMountOpen = (run) => {
|
|
44341
|
+
pendingMountOpens.push(run);
|
|
44342
|
+
if (mountOpenFlushScheduled) {
|
|
44343
|
+
return;
|
|
44344
|
+
}
|
|
44345
|
+
mountOpenFlushScheduled = true;
|
|
44346
|
+
queueMicrotask(() => {
|
|
44347
|
+
const entries = pendingMountOpens;
|
|
44348
|
+
pendingMountOpens = [];
|
|
44349
|
+
mountOpenFlushScheduled = false;
|
|
44350
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
44351
|
+
entries[i]();
|
|
44352
|
+
}
|
|
44353
|
+
});
|
|
44354
|
+
};
|
|
44355
|
+
|
|
44356
|
+
// Where the popup's open state is kept, when it is kept anywhere: `navState`
|
|
44357
|
+
// resolved to the `{ id, type }` useNavState wants.
|
|
44358
|
+
//
|
|
44359
|
+
// `true` takes the popup's own id — a popup a `--navi-open` command can name is
|
|
44360
|
+
// a popup that already has a stable one, and that id is what identifies its
|
|
44361
|
+
// open state too.
|
|
44362
|
+
const NO_NAV_STATE = { id: undefined, type: "replace" };
|
|
44363
|
+
const resolveNavStateProp = (navState, popupId, name) => {
|
|
44364
|
+
if (!navState) {
|
|
44365
|
+
return NO_NAV_STATE;
|
|
44366
|
+
}
|
|
44367
|
+
if (navState === true) {
|
|
44368
|
+
return { id: popupId, type: "replace" };
|
|
44369
|
+
}
|
|
44370
|
+
if (typeof navState === "string") {
|
|
44371
|
+
return { id: navState, type: "replace" };
|
|
44372
|
+
}
|
|
44373
|
+
return { id: navState.id || popupId, type: navState.type || "replace" };
|
|
44374
|
+
};
|
|
44375
|
+
|
|
44376
|
+
/**
|
|
44377
|
+
* Keeps an open controller in sync with where the caller says the popup should
|
|
44378
|
+
* be: an `open`/`defaultOpen` pair, a `signal`, or a `navState` — the open
|
|
44379
|
+
* state written into the history entry, so a screen left and come back to finds
|
|
44380
|
+
* its popup as it was.
|
|
44381
|
+
*
|
|
44382
|
+
* Shared between `useOpenControllerByProps` below (Dialog/Popover driving their
|
|
44383
|
+
* own controller), `picker_custom.jsx` (which owns its controller but wants
|
|
44384
|
+
* the same skip-if-already-matching / open-or-requestClose control flow) and
|
|
44385
|
+
* `expandable.jsx` (open in flow rather than on a layer, same decision).
|
|
44386
|
+
*
|
|
44387
|
+
* @param {{ open: (e: Event, detail?: object) => void, requestClose: (e: Event, detail?: object) => void, opened: boolean }} openController
|
|
44388
|
+
* @param {{ id?: string, open?: boolean|"interaction", defaultOpen?: boolean|"interaction", signal?: import("@preact/signals").Signal<boolean>, navState?: boolean|string|{id?: string, type?: "push"|"replace"} }} props
|
|
44389
|
+
* @param {string} [name] What the dev warnings call the thing being opened.
|
|
44390
|
+
*/
|
|
44391
|
+
const useOpenPropsEffectOnOpenController = (
|
|
44392
|
+
openController,
|
|
44393
|
+
props,
|
|
44394
|
+
name = "popup",
|
|
44395
|
+
) => {
|
|
44396
|
+
const { signal, defaultOpen, navState } = props;
|
|
44397
|
+
const { id: navStateId, type: navStateType } = resolveNavStateProp(
|
|
44398
|
+
navState,
|
|
44399
|
+
props.id);
|
|
44400
|
+
// Called unconditionally (it answers with no-ops for an absent id), like
|
|
44401
|
+
// every other hook here.
|
|
44402
|
+
const [navStateValue, enterNavState, leaveNavState] = useNavState(
|
|
44403
|
+
navStateId,
|
|
44404
|
+
{ type: navStateType },
|
|
44405
|
+
);
|
|
44406
|
+
// What the caller holds, however they hold it: the history entry when there
|
|
44407
|
+
// is a `navState`, an `open` they re-render themselves, or a `signal` this
|
|
44408
|
+
// hook also writes (see onOpenedChange below). Reading .value during render
|
|
44409
|
+
// is what subscribes the popup to a signal; reading the document state is
|
|
44410
|
+
// what subscribes it to the history entry, back button included.
|
|
44411
|
+
const open = navStateId
|
|
44412
|
+
? Boolean(navStateValue)
|
|
44413
|
+
: signal
|
|
44414
|
+
? signal.value
|
|
44415
|
+
: props.open;
|
|
44416
|
+
// Assigned on every render, like openEffect, so it always closes over the
|
|
44417
|
+
// latest prop: a popup that opens or closes on its own (Escape, backdrop, a
|
|
44418
|
+
// --navi-close command) writes what happened where the caller keeps it, so
|
|
44419
|
+
// whoever holds it always reads where the popup is.
|
|
44420
|
+
openController.onOpenedChange =
|
|
44421
|
+
navStateId || signal
|
|
44422
|
+
? (opened, event) => {
|
|
44423
|
+
if (navStateId) {
|
|
44424
|
+
if (opened) {
|
|
44425
|
+
enterNavState();
|
|
44426
|
+
} else {
|
|
44427
|
+
// Under type "push" a cancel goes back rather than rewriting the
|
|
44428
|
+
// entry, so everything else written to the url while the popup
|
|
44429
|
+
// was open goes back with it (see useNavState's own leave()).
|
|
44430
|
+
leaveNavState({ isBack: Boolean(event?.detail?.isCancel) });
|
|
44431
|
+
}
|
|
44432
|
+
}
|
|
44433
|
+
if (signal) {
|
|
44434
|
+
signal.value = opened;
|
|
44435
|
+
}
|
|
44436
|
+
}
|
|
44437
|
+
: null;
|
|
44438
|
+
// Tracks whether the effect below has ever run before — only the very
|
|
44439
|
+
// first run gets the "mount already open" treatment (`open` truthy from
|
|
44440
|
+
// the start, or the uncontrolled, mount-only `defaultOpen`); every
|
|
44441
|
+
// subsequent `open` change is a real, later toggle and should animate
|
|
44442
|
+
// normally like any other interactive open/close.
|
|
44443
|
+
const isFirstRunRef = useRef(true);
|
|
44444
|
+
|
|
44445
|
+
useLayoutEffect(() => {
|
|
44446
|
+
const isFirstRun = isFirstRunRef.current;
|
|
44447
|
+
isFirstRunRef.current = false;
|
|
44448
|
+
|
|
44449
|
+
if (isFirstRun) {
|
|
44450
|
+
const mountOpenReason = open || defaultOpen;
|
|
44451
|
+
if (mountOpenReason) {
|
|
44452
|
+
// Whether this popup being open is something that just happened, or
|
|
44453
|
+
// something that was already true when the page appeared. "interaction"
|
|
44454
|
+
// says the mount IS the opening — the popup exists because the user
|
|
44455
|
+
// just asked for it — so the entrance plays like any other open. Any
|
|
44456
|
+
// other truthy value means it was simply already open: nothing was ever
|
|
44457
|
+
// shown as "closed" for the user to see it transition away from, so the
|
|
44458
|
+
// entrance is skipped (`silent`, see popover.jsx's own openEffect).
|
|
44459
|
+
//
|
|
44460
|
+
// Deferred + batched (see scheduleMountOpen above) rather than called
|
|
44461
|
+
// directly, so nested popups that both mount already-open end up
|
|
44462
|
+
// stacked ancestor-first instead of Preact's own child-first effect
|
|
44463
|
+
// order.
|
|
44464
|
+
scheduleMountOpen(() =>
|
|
44465
|
+
openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
|
|
44466
|
+
silent: mountOpenReason !== "interaction",
|
|
44467
|
+
}),
|
|
44468
|
+
);
|
|
44469
|
+
}
|
|
44470
|
+
return;
|
|
44471
|
+
}
|
|
44472
|
+
|
|
44473
|
+
if (open === undefined) {
|
|
44474
|
+
return;
|
|
44475
|
+
}
|
|
44476
|
+
// Skip when the controller is already in the desired state.
|
|
44477
|
+
// openController.opened tracks actual open/close (updated by onopen/onclose,
|
|
44478
|
+
// not by renders) so it is the authoritative check against feedback loops.
|
|
44479
|
+
if (open === openController.opened) {
|
|
44480
|
+
return;
|
|
44481
|
+
}
|
|
44482
|
+
if (open) {
|
|
44483
|
+
openController.open(new CustomEvent("open_by_prop", { detail: {} }));
|
|
44484
|
+
} else {
|
|
44485
|
+
openController.requestClose(
|
|
44486
|
+
new CustomEvent("close_by_prop", { detail: {} }),
|
|
44487
|
+
{ isCancel: true },
|
|
44488
|
+
);
|
|
44489
|
+
}
|
|
44490
|
+
// The request can be refused (a busy form denying the close): the popup
|
|
44491
|
+
// then stays where it was, and whoever holds the open state is told so —
|
|
44492
|
+
// otherwise it would keep saying "closed" about a popup still open.
|
|
44493
|
+
if (signal) {
|
|
44494
|
+
signal.value = openController.opened;
|
|
44495
|
+
}
|
|
44496
|
+
if (navStateId && openController.opened) {
|
|
44497
|
+
enterNavState();
|
|
44498
|
+
}
|
|
44499
|
+
}, [open]);
|
|
44500
|
+
};
|
|
44501
|
+
|
|
44502
|
+
const useOpenControllerByProps = (props, name) => {
|
|
44503
|
+
const { onClose } = props;
|
|
44504
|
+
// Lets an uncontrolled consumer (no openController of its own) still react
|
|
44505
|
+
// to a self-initiated close (Escape, backdrop click, its own close button)
|
|
44506
|
+
// without having to own a controller just to observe it — onClose is
|
|
44507
|
+
// called on every real close, matching createOpenController's own
|
|
44508
|
+
// { onRequestClose, onClose } contract (never denies the close itself).
|
|
44509
|
+
const openController = useOpenController(() =>
|
|
44510
|
+
onClose ? { onClose } : undefined,
|
|
44511
|
+
);
|
|
44512
|
+
useOpenPropsEffectOnOpenController(openController, props, name);
|
|
44513
|
+
return openController;
|
|
44514
|
+
};
|
|
44515
|
+
|
|
43903
44516
|
/**
|
|
43904
44517
|
* Runs `fn` and commits whatever it re-renders before returning, instead of
|
|
43905
44518
|
* letting Preact batch it into the next microtask. Layout effects of what gets
|
|
@@ -44188,7 +44801,7 @@ const MOUNT_DEFAULT = "from-first-open";
|
|
|
44188
44801
|
const usePopupContentMount = (
|
|
44189
44802
|
openController,
|
|
44190
44803
|
ref,
|
|
44191
|
-
{
|
|
44804
|
+
{ mount = MOUNT_DEFAULT },
|
|
44192
44805
|
) => {
|
|
44193
44806
|
const mountedAlways = mount === "always";
|
|
44194
44807
|
const [contentMounted, setContentMounted] = useState(
|
|
@@ -44228,7 +44841,7 @@ const usePopupContentMount = (
|
|
|
44228
44841
|
}
|
|
44229
44842
|
}, [mountedAlways]);
|
|
44230
44843
|
|
|
44231
|
-
return contentMounted
|
|
44844
|
+
return contentMounted;
|
|
44232
44845
|
};
|
|
44233
44846
|
|
|
44234
44847
|
installImportMetaCssBuild(import.meta);/**
|
|
@@ -44258,10 +44871,17 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
44258
44871
|
*
|
|
44259
44872
|
* The root never looks at its children: which parts are there, in which order,
|
|
44260
44873
|
* is none of its business. It publishes a context and the parts take what they
|
|
44261
|
-
* need from it
|
|
44262
|
-
*
|
|
44263
|
-
*
|
|
44264
|
-
*
|
|
44874
|
+
* need from it.
|
|
44875
|
+
*
|
|
44876
|
+
* Deciding to open or to close is the same decision a Dialog or a Popover
|
|
44877
|
+
* takes — the same props behind it (`open`/`defaultOpen`/`signal`/`navState`),
|
|
44878
|
+
* the same commands in front of it, the same refusable close — so it is taken
|
|
44879
|
+
* in the same place: an open controller (see open_controller.js). What belongs
|
|
44880
|
+
* to an expandable is only what opening LOOKS like, which is `openEffect` and
|
|
44881
|
+
* the cleanup it returns. Both render synchronously (flushSyncRendering)
|
|
44882
|
+
* before measuring, so the DOM is in the state they are about to measure —
|
|
44883
|
+
* that, and not any inspection of the children, is what makes a part free to
|
|
44884
|
+
* re-render later than the root (a memoized subtree does exactly that).
|
|
44265
44885
|
*
|
|
44266
44886
|
* Reach for it knowingly: expanding in-flow SHIFTS the layout — everything
|
|
44267
44887
|
* below (or beside) moves when it opens. A Popover, Dialog, Picker or Callout
|
|
@@ -44281,17 +44901,18 @@ installImportMetaCssBuild(import.meta);/**
|
|
|
44281
44901
|
* and answer the `navi_command`/`navi_request_open`/`navi_request_close`
|
|
44282
44902
|
* events.
|
|
44283
44903
|
*
|
|
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
|
|
44904
|
+
* Content is not built until the first expansion and stays built afterwards:
|
|
44905
|
+
* a popup's policy, and its code (`usePopupContentMount`, see
|
|
44906
|
+
* popup_content_mount.js) — `"always"` builds it right away, `"while-opened"`
|
|
44907
|
+
* throws it away once the collapse settles, so a closing animation still plays
|
|
44908
|
+
* on real content.
|
|
44288
44909
|
*
|
|
44289
44910
|
* The animation is a REVEAL, not a resize: the expandable's own footprint
|
|
44290
44911
|
* grows/shrinks progressively (the content's grid track interpolates
|
|
44291
44912
|
* 0fr <-> 1fr — rows for the stacked layout, columns for `layout="column"`),
|
|
44292
44913
|
* 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
|
-
*
|
|
44914
|
+
* (its animated dimension is frozen to the measured final value, see
|
|
44915
|
+
* freezeContentSize) and the container simply uncovers it. Text never rewraps
|
|
44295
44916
|
* mid-animation. The content is revealed from its UI side (pinned against the
|
|
44296
44917
|
* UI when it comes first). Once settled open the clipping is released, so a
|
|
44297
44918
|
* popover or focus ring inside is not cut at the edges.
|
|
@@ -44443,6 +45064,8 @@ const useExpandableContext = partName => {
|
|
|
44443
45064
|
* open?: boolean,
|
|
44444
45065
|
* defaultOpen?: boolean,
|
|
44445
45066
|
* signal?: import("@preact/signals").Signal<boolean>,
|
|
45067
|
+
* navState?: boolean | string | { id?: string, type?: "push" | "replace" },
|
|
45068
|
+
* onClose?: (event: Event) => void,
|
|
44446
45069
|
* onToggle?: (event: Event) => void,
|
|
44447
45070
|
* action?: Function,
|
|
44448
45071
|
* loading?: boolean,
|
|
@@ -44464,10 +45087,19 @@ const useExpandableContext = partName => {
|
|
|
44464
45087
|
* `<Expandable.Content>` as children instead.
|
|
44465
45088
|
* @param open - Drives the state from outside: the expandable opens/closes to
|
|
44466
45089
|
* match every change of this prop, but user interaction can still toggle it
|
|
44467
|
-
* in between
|
|
45090
|
+
* in between. Same open controller as Dialog/Popover, so the four props
|
|
45091
|
+
* below behave exactly as they do there (see open_controller.js).
|
|
44468
45092
|
* @param defaultOpen - Uncontrolled, mount-only initial state.
|
|
44469
45093
|
* @param signal - Two-way binding: the expandable follows the signal and
|
|
44470
45094
|
* writes back into it whenever it toggles on its own. Excludes `open`.
|
|
45095
|
+
* @param navState - Keeps the open state in the history entry, so a screen
|
|
45096
|
+
* left and come back to finds its sections as they were: `true` uses the
|
|
45097
|
+
* expandable's own `id`, a string names the key, `{ id, type }` chooses
|
|
45098
|
+
* between rewriting the entry ("replace", the default) and pushing one
|
|
45099
|
+
* ("push"), where closing goes back. Source of truth — excludes
|
|
45100
|
+
* `open`/`signal`.
|
|
45101
|
+
* @param onClose - Called when the expandable actually closes, whatever asked
|
|
45102
|
+
* for it. Not preventable.
|
|
44471
45103
|
* @param onToggle - Listens the "toggle" event dispatched on the root (a
|
|
44472
45104
|
* ToggleEvent with newState/oldState where supported). Fires on every actual
|
|
44473
45105
|
* state change, never on mount.
|
|
@@ -44487,15 +45119,17 @@ const useExpandableContext = partName => {
|
|
|
44487
45119
|
* whatever order they are written in — and an expandable with no
|
|
44488
45120
|
* `<Expandable.UI>` at all, driven from `open`/`signal` by a toggle of the
|
|
44489
45121
|
* app's own, can still say which way it opens.
|
|
44490
|
-
* @param autoFocus - Off by default (the
|
|
44491
|
-
*
|
|
44492
|
-
*
|
|
44493
|
-
*
|
|
44494
|
-
*
|
|
45122
|
+
* @param autoFocus - Off by default (the keyboard stays where it is when the
|
|
45123
|
+
* expandable opens). `true` hands the focus to the content through the same
|
|
45124
|
+
* transfer a popup uses — the ladder picks the target (`navi-autofocus`
|
|
45125
|
+
* first, see focus_transfer.js) and closing gives the keyboard back where it
|
|
45126
|
+
* came from. An expandable that mounts already open never takes it. Whatever
|
|
45127
|
+
* the setting, closing while the focus sits inside the content hands it back
|
|
45128
|
+
* to the UI part (it would otherwise be lost to the closed, inert content).
|
|
44495
45129
|
* @param maxContentHeight - Caps the content height; taller content scrolls
|
|
44496
45130
|
* inside the expandable instead of growing it.
|
|
44497
|
-
* @param mount - When the content is built and thrown away,
|
|
44498
|
-
*
|
|
45131
|
+
* @param mount - When the content is built and thrown away, a popup's own
|
|
45132
|
+
* three values and a popup's own code (see popup_content_mount.js). `"from-first-open"` (the
|
|
44499
45133
|
* default) builds it on the first expansion and keeps it afterwards.
|
|
44500
45134
|
* `"always"` builds it right away; in layout="column" it also gives the
|
|
44501
45135
|
* closed expandable its content's height (the content is kept laid out at
|
|
@@ -44506,12 +45140,17 @@ const useExpandableContext = partName => {
|
|
|
44506
45140
|
*/
|
|
44507
45141
|
const Expandable = props => {
|
|
44508
45142
|
import.meta.css = [css$Q, "@jsenv/navi/src/control/expandable/expandable.jsx"];
|
|
45143
|
+
/* The open props are read from `props` by the open controller below; they
|
|
45144
|
+
are named here only to keep them out of `rest`, and so out of the DOM. */
|
|
45145
|
+
/* eslint-disable no-unused-vars */
|
|
44509
45146
|
const {
|
|
44510
45147
|
ref,
|
|
44511
45148
|
ui,
|
|
44512
45149
|
open,
|
|
44513
45150
|
defaultOpen,
|
|
44514
45151
|
signal,
|
|
45152
|
+
navState,
|
|
45153
|
+
onClose,
|
|
44515
45154
|
action,
|
|
44516
45155
|
loading,
|
|
44517
45156
|
animation = false,
|
|
@@ -44526,35 +45165,43 @@ const Expandable = props => {
|
|
|
44526
45165
|
children,
|
|
44527
45166
|
...rest
|
|
44528
45167
|
} = props;
|
|
45168
|
+
/* eslint-enable no-unused-vars */
|
|
45169
|
+
|
|
44529
45170
|
const defaultRef = useRef();
|
|
44530
45171
|
const rootRef = ref || defaultRef;
|
|
44531
45172
|
const uiRef = useRef();
|
|
44532
45173
|
const contentContainerRef = useRef();
|
|
44533
45174
|
const contentId = useId();
|
|
44534
45175
|
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;
|
|
45176
|
+
const closedContentSized = isColumn && mount === "always";
|
|
44543
45177
|
const hasAction = Boolean(action);
|
|
44544
45178
|
const effectiveAction = useAction(action);
|
|
44545
45179
|
const {
|
|
44546
45180
|
loading: actionLoading
|
|
44547
45181
|
} = useActionStatus(effectiveAction);
|
|
44548
|
-
const [contentMounted, setContentMounted] = useState(() => mountedAlways || opened);
|
|
44549
45182
|
|
|
44550
|
-
//
|
|
44551
|
-
// (see
|
|
45183
|
+
// Registered before the open controller, so this cleanup runs before its own
|
|
45184
|
+
// unmount safety net (see useOpenController): a close fired on the way out
|
|
45185
|
+
// still does its bookkeeping, it just has no tree left to move.
|
|
45186
|
+
const unmountedRef = useRef(false);
|
|
45187
|
+
useLayoutEffect(() => {
|
|
45188
|
+
return () => {
|
|
45189
|
+
unmountedRef.current = true;
|
|
45190
|
+
};
|
|
45191
|
+
}, []);
|
|
45192
|
+
const openController = useOpenControllerByProps(props, "expandable");
|
|
45193
|
+
// What the controller decided, mirrored for the render (aria-expanded, the
|
|
45194
|
+
// content's inert, the marker). Written only from `openEffect` and the
|
|
45195
|
+
// cleanup it returns, and synchronously (see flushSyncRendering), so the DOM
|
|
45196
|
+
// is already in the new state when what follows measures it.
|
|
45197
|
+
const [opened, setOpened] = useState(false);
|
|
45198
|
+
// Fully open (or fully closed) and no longer moving — what releases the
|
|
45199
|
+
// clipping, see the CSS.
|
|
44552
45200
|
const [settled, setSettled] = useState(true);
|
|
45201
|
+
const contentMounted = usePopupContentMount(openController, contentContainerRef, {
|
|
45202
|
+
mount
|
|
45203
|
+
});
|
|
44553
45204
|
|
|
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
45205
|
// The pointer press that is about to toggle can blur the focused field
|
|
44559
45206
|
// before the click ever fires — so what held the focus has to be remembered
|
|
44560
45207
|
// at pointerdown time.
|
|
@@ -44568,10 +45215,7 @@ const Expandable = props => {
|
|
|
44568
45215
|
// released once the movement settles.
|
|
44569
45216
|
const freezeContentSize = () => {
|
|
44570
45217
|
const contentContainer = contentContainerRef.current;
|
|
44571
|
-
const contentElement = contentContainer
|
|
44572
|
-
if (!contentElement) {
|
|
44573
|
-
return;
|
|
44574
|
-
}
|
|
45218
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
44575
45219
|
const rect = contentElement.getBoundingClientRect();
|
|
44576
45220
|
if (isColumn) {
|
|
44577
45221
|
// Both, not just the width: a max-height-capped content otherwise
|
|
@@ -44583,50 +45227,147 @@ const Expandable = props => {
|
|
|
44583
45227
|
}
|
|
44584
45228
|
};
|
|
44585
45229
|
|
|
44586
|
-
//
|
|
44587
|
-
//
|
|
44588
|
-
//
|
|
44589
|
-
|
|
44590
|
-
|
|
44591
|
-
|
|
44592
|
-
|
|
44593
|
-
|
|
44594
|
-
|
|
44595
|
-
|
|
44596
|
-
|
|
45230
|
+
// A movement still playing: what a new one cancels before starting, so a
|
|
45231
|
+
// reveal interrupted halfway does not have the previous settle release its
|
|
45232
|
+
// frozen size under it.
|
|
45233
|
+
const cancelSettleWatchRef = useRef(null);
|
|
45234
|
+
const cancelSettleWatch = () => {
|
|
45235
|
+
cancelSettleWatchRef.current?.();
|
|
45236
|
+
cancelSettleWatchRef.current = null;
|
|
45237
|
+
};
|
|
45238
|
+
const watchSettle = () => {
|
|
45239
|
+
const contentContainer = contentContainerRef.current;
|
|
45240
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
45241
|
+
cancelSettleWatchRef.current = whenTransitionSettles(contentContainer, () => {
|
|
45242
|
+
cancelSettleWatchRef.current = null;
|
|
45243
|
+
contentElement.style.width = "";
|
|
45244
|
+
contentElement.style.height = "";
|
|
45245
|
+
setSettled(true);
|
|
45246
|
+
});
|
|
45247
|
+
};
|
|
45248
|
+
|
|
45249
|
+
// The reveal needs the content at its final size before the track starts
|
|
45250
|
+
// moving, and that size only exists in the open state — the reflow trick
|
|
45251
|
+
// (see instructions.md, CSS section), with transitions suppressed BEFORE the
|
|
45252
|
+
// first layout read: this runs pre-paint, so any earlier read would itself be
|
|
45253
|
+
// the first recalc of the open state and would start the track transition.
|
|
45254
|
+
const armReveal = startRect => {
|
|
45255
|
+
const contentContainer = contentContainerRef.current;
|
|
45256
|
+
const contentElement = contentContainer.firstElementChild.firstElementChild;
|
|
45257
|
+
contentContainer.style.transitionProperty = "none";
|
|
45258
|
+
const finalRect = contentElement.getBoundingClientRect();
|
|
45259
|
+
if (isColumn) {
|
|
45260
|
+
contentElement.style.width = `${finalRect.width}px`;
|
|
45261
|
+
contentElement.style.height = `${finalRect.height}px`;
|
|
45262
|
+
} else {
|
|
45263
|
+
contentElement.style.height = `${finalRect.height}px`;
|
|
44597
45264
|
}
|
|
44598
|
-
|
|
44599
|
-
|
|
44600
|
-
|
|
44601
|
-
|
|
44602
|
-
|
|
45265
|
+
// Put the tracks back where the last paint left them and let the
|
|
45266
|
+
// transition play from there. In fr — px does not interpolate with fr.
|
|
45267
|
+
const startFrOf = (startSize, finalSize) => finalSize > 0 ? startSize / finalSize : 0;
|
|
45268
|
+
if (isColumn) {
|
|
45269
|
+
contentContainer.style.gridTemplateColumns = `${startFrOf(startRect ? startRect.width : 0, finalRect.width)}fr`;
|
|
45270
|
+
if (!closedContentSized) {
|
|
45271
|
+
// The height opens alongside the width (a closed column expandable is
|
|
45272
|
+
// only as tall as its UI) — unless the closed content already sizes
|
|
45273
|
+
// it, where only the width has anywhere to go.
|
|
45274
|
+
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
44603
45275
|
}
|
|
44604
|
-
setContentMounted(true);
|
|
44605
45276
|
} 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;
|
|
45277
|
+
contentContainer.style.gridTemplateRows = `${startFrOf(startRect ? startRect.height : 0, finalRect.height)}fr`;
|
|
44623
45278
|
}
|
|
45279
|
+
// That starting frame must be genuinely rendered to transition from it,
|
|
45280
|
+
// and transitions re-enabled BEFORE the flip back to the open value —
|
|
45281
|
+
// same order as popover.jsx's own reflow trick.
|
|
45282
|
+
contentContainer.getBoundingClientRect();
|
|
45283
|
+
contentContainer.style.transitionProperty = "";
|
|
45284
|
+
contentContainer.style.gridTemplateColumns = "";
|
|
45285
|
+
contentContainer.style.gridTemplateRows = "";
|
|
45286
|
+
};
|
|
45287
|
+
|
|
45288
|
+
// What opening LOOKS like here, and how to undo it — the one thing an
|
|
45289
|
+
// expandable owns that a popup does not (see open_controller.js). Reassigned
|
|
45290
|
+
// on every render so it always closes over the latest props.
|
|
45291
|
+
openController.openEffect = openEvent => {
|
|
45292
|
+
const contentContainer = contentContainerRef.current;
|
|
45293
|
+
// `silent`: the expandable was already open when the page appeared
|
|
45294
|
+
// (`open`/`defaultOpen` at mount). Nothing changed for anyone to be told
|
|
45295
|
+
// about, nothing was ever shown closed to move away from, and a page must
|
|
45296
|
+
// not have its focus stolen by a section that was simply already open — so
|
|
45297
|
+
// an opening that was never an opening plays nothing, says nothing and
|
|
45298
|
+
// takes nothing. The action it carries still runs: its content is due.
|
|
45299
|
+
const silent = Boolean(openEvent.detail.silent);
|
|
45300
|
+
const revealing = animation && !silent && Boolean(contentContainer);
|
|
45301
|
+
// Where the last paint left the track — 0 when fully closed, partway when
|
|
45302
|
+
// reopening during a collapse — read while the DOM still says closed.
|
|
45303
|
+
const startRect = revealing ? contentContainer.getBoundingClientRect() : null;
|
|
45304
|
+
cancelSettleWatch();
|
|
45305
|
+
flushSyncRendering(() => {
|
|
45306
|
+
setOpened(true);
|
|
45307
|
+
setSettled(!revealing);
|
|
45308
|
+
});
|
|
44624
45309
|
if (hasAction) {
|
|
44625
|
-
|
|
44626
|
-
|
|
44627
|
-
|
|
45310
|
+
runUnwatched(() => effectiveAction.run());
|
|
45311
|
+
}
|
|
45312
|
+
// autoFocus off (the default): the keyboard stays where it is, and with no
|
|
45313
|
+
// transfer there is nothing to restore on close either.
|
|
45314
|
+
const restoreFocus = autoFocus && !silent && contentContainer ? openController.transferFocusOnOpen(contentContainer) : null;
|
|
45315
|
+
if (revealing) {
|
|
45316
|
+
armReveal(startRect);
|
|
45317
|
+
watchSettle();
|
|
45318
|
+
}
|
|
45319
|
+
if (!silent) {
|
|
45320
|
+
rootRef.current.dispatchEvent(createToggleEvent(true));
|
|
45321
|
+
}
|
|
45322
|
+
return closeEvent => {
|
|
45323
|
+
if (unmountedRef.current) {
|
|
45324
|
+
return;
|
|
45325
|
+
}
|
|
45326
|
+
const contentContainerAtClose = contentContainerRef.current;
|
|
45327
|
+
// Read while the content still holds what it holds: the close is about
|
|
45328
|
+
// to make it inert, which blurs whatever is inside it.
|
|
45329
|
+
const activeElement = document.activeElement;
|
|
45330
|
+
const focusedBeforeClose = !activeElement || activeElement === document.body ? focusedAtPointerDownRef.current : activeElement;
|
|
45331
|
+
focusedAtPointerDownRef.current = null;
|
|
45332
|
+
if (hasAction) {
|
|
44628
45333
|
effectiveAction.abort();
|
|
44629
45334
|
}
|
|
45335
|
+
cancelSettleWatch();
|
|
45336
|
+
const collapsing = animation && Boolean(contentContainerAtClose);
|
|
45337
|
+
if (collapsing) {
|
|
45338
|
+
// Now, while the content is still fully laid out — the collapsing
|
|
45339
|
+
// track uncovers a content frozen at that size.
|
|
45340
|
+
freezeContentSize();
|
|
45341
|
+
}
|
|
45342
|
+
flushSyncRendering(() => {
|
|
45343
|
+
setOpened(false);
|
|
45344
|
+
setSettled(!collapsing);
|
|
45345
|
+
});
|
|
45346
|
+
if (restoreFocus) {
|
|
45347
|
+
restoreFocus(closeEvent);
|
|
45348
|
+
} else if (focusedBeforeClose && contentContainerAtClose && contentContainerAtClose.contains(focusedBeforeClose)) {
|
|
45349
|
+
const uiElement = uiRef.current;
|
|
45350
|
+
if (uiElement) {
|
|
45351
|
+
moveFocusTo(uiElement);
|
|
45352
|
+
} else {
|
|
45353
|
+
// Nothing of the expandable's own can hold the keyboard (no UI
|
|
45354
|
+
// part): the focus only has to leave the content becoming inert.
|
|
45355
|
+
focusedBeforeClose.blur();
|
|
45356
|
+
}
|
|
45357
|
+
}
|
|
45358
|
+
if (collapsing) {
|
|
45359
|
+
watchSettle();
|
|
45360
|
+
}
|
|
45361
|
+
rootRef.current.dispatchEvent(createToggleEvent(false));
|
|
45362
|
+
};
|
|
45363
|
+
};
|
|
45364
|
+
const toggle = event => {
|
|
45365
|
+
if (openController.opened) {
|
|
45366
|
+
openController.requestClose(event, {
|
|
45367
|
+
isCancel: true
|
|
45368
|
+
});
|
|
45369
|
+
} else {
|
|
45370
|
+
openController.open(event);
|
|
44630
45371
|
}
|
|
44631
45372
|
};
|
|
44632
45373
|
const findFirstFocusableInContent = () => {
|
|
@@ -44634,133 +45375,11 @@ const Expandable = props => {
|
|
|
44634
45375
|
if (!contentContainer) {
|
|
44635
45376
|
return null;
|
|
44636
45377
|
}
|
|
44637
|
-
const autofocusElement = contentContainer.querySelector("[autofocus]");
|
|
44638
|
-
if (autofocusElement) {
|
|
44639
|
-
return autofocusElement;
|
|
44640
|
-
}
|
|
44641
45378
|
return findAfter(contentContainer, elementIsFocusable, {
|
|
44642
45379
|
root: contentContainer
|
|
44643
45380
|
});
|
|
44644
45381
|
};
|
|
44645
45382
|
|
|
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
45383
|
// closedContentSized (column + mount="always"): the closed content sizes
|
|
44765
45384
|
// the height (see the CSS), which is only right if it lies at its OPEN
|
|
44766
45385
|
// width — at its natural closed width (a 0-wide track) it would wrap
|
|
@@ -44784,13 +45403,6 @@ const Expandable = props => {
|
|
|
44784
45403
|
contentContainer.getBoundingClientRect();
|
|
44785
45404
|
contentContainer.style.transitionProperty = "";
|
|
44786
45405
|
}, [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
45406
|
const onRootKeyDown = keyboardEvent => {
|
|
44795
45407
|
if (!arrowKeyShortcuts) {
|
|
44796
45408
|
return;
|
|
@@ -44819,9 +45431,9 @@ const Expandable = props => {
|
|
|
44819
45431
|
if (document.activeElement !== uiRef.current) {
|
|
44820
45432
|
return;
|
|
44821
45433
|
}
|
|
44822
|
-
if (!
|
|
45434
|
+
if (!openController.opened) {
|
|
44823
45435
|
keyboardEvent.preventDefault();
|
|
44824
|
-
|
|
45436
|
+
openController.open(keyboardEvent);
|
|
44825
45437
|
return;
|
|
44826
45438
|
}
|
|
44827
45439
|
const firstFocusableElementInContent = findFirstFocusableInContent();
|
|
@@ -44829,20 +45441,21 @@ const Expandable = props => {
|
|
|
44829
45441
|
return;
|
|
44830
45442
|
}
|
|
44831
45443
|
keyboardEvent.preventDefault();
|
|
44832
|
-
firstFocusableElementInContent
|
|
45444
|
+
moveFocusTo(firstFocusableElementInContent);
|
|
44833
45445
|
return;
|
|
44834
45446
|
}
|
|
44835
45447
|
if (key === closeKeyShortcut) {
|
|
44836
|
-
if (!
|
|
45448
|
+
if (!openController.opened) {
|
|
44837
45449
|
return;
|
|
44838
45450
|
}
|
|
44839
45451
|
const uiElement = uiRef.current;
|
|
45452
|
+
keyboardEvent.preventDefault();
|
|
44840
45453
|
if (document.activeElement === uiElement) {
|
|
44841
|
-
|
|
44842
|
-
|
|
45454
|
+
openController.requestClose(keyboardEvent, {
|
|
45455
|
+
isCancel: true
|
|
45456
|
+
});
|
|
44843
45457
|
} else {
|
|
44844
|
-
|
|
44845
|
-
uiElement.focus();
|
|
45458
|
+
moveFocusTo(uiElement);
|
|
44846
45459
|
}
|
|
44847
45460
|
}
|
|
44848
45461
|
};
|
|
@@ -44861,7 +45474,7 @@ const Expandable = props => {
|
|
|
44861
45474
|
return;
|
|
44862
45475
|
}
|
|
44863
45476
|
}
|
|
44864
|
-
|
|
45477
|
+
toggle(clickEvent);
|
|
44865
45478
|
};
|
|
44866
45479
|
|
|
44867
45480
|
// Space/Enter on the UI part itself (role button) — a key pressed on a
|
|
@@ -44878,7 +45491,7 @@ const Expandable = props => {
|
|
|
44878
45491
|
} = keyboardEvent;
|
|
44879
45492
|
if (key === " " || key === "Enter") {
|
|
44880
45493
|
keyboardEvent.preventDefault();
|
|
44881
|
-
|
|
45494
|
+
toggle(keyboardEvent);
|
|
44882
45495
|
}
|
|
44883
45496
|
};
|
|
44884
45497
|
|
|
@@ -44896,8 +45509,7 @@ const Expandable = props => {
|
|
|
44896
45509
|
hasAction,
|
|
44897
45510
|
effectiveAction,
|
|
44898
45511
|
markerDirection,
|
|
44899
|
-
|
|
44900
|
-
toggleTo,
|
|
45512
|
+
openController,
|
|
44901
45513
|
onUIClick,
|
|
44902
45514
|
onUIPointerDown,
|
|
44903
45515
|
onUIKeyDown,
|
|
@@ -44935,11 +45547,17 @@ const Expandable = props => {
|
|
|
44935
45547
|
},
|
|
44936
45548
|
onnavi_request_open: e => {
|
|
44937
45549
|
rest.onnavi_request_open?.(e);
|
|
44938
|
-
|
|
45550
|
+
openController.open(e);
|
|
44939
45551
|
},
|
|
44940
45552
|
onnavi_request_close: e => {
|
|
44941
45553
|
rest.onnavi_request_close?.(e);
|
|
44942
|
-
|
|
45554
|
+
const closing = openController.requestClose(e, {
|
|
45555
|
+
isCancel: e.detail?.isCancel
|
|
45556
|
+
});
|
|
45557
|
+
if (!closing) {
|
|
45558
|
+
// Said back to whoever asked: --navi-close:all stops climbing here.
|
|
45559
|
+
e.preventDefault();
|
|
45560
|
+
}
|
|
44943
45561
|
},
|
|
44944
45562
|
onKeyDown: e => {
|
|
44945
45563
|
rest.onKeyDown?.(e);
|
|
@@ -44979,7 +45597,7 @@ const ExpandableUI = ({
|
|
|
44979
45597
|
opened,
|
|
44980
45598
|
loading,
|
|
44981
45599
|
markerDirection,
|
|
44982
|
-
|
|
45600
|
+
openController,
|
|
44983
45601
|
onUIClick,
|
|
44984
45602
|
onUIPointerDown,
|
|
44985
45603
|
onUIKeyDown,
|
|
@@ -45006,11 +45624,13 @@ const ExpandableUI = ({
|
|
|
45006
45624
|
onnavi_command: e => {
|
|
45007
45625
|
onNaviCommand(e);
|
|
45008
45626
|
},
|
|
45009
|
-
onnavi_request_open:
|
|
45010
|
-
|
|
45627
|
+
onnavi_request_open: e => {
|
|
45628
|
+
openController.open(e);
|
|
45011
45629
|
},
|
|
45012
|
-
onnavi_request_close:
|
|
45013
|
-
|
|
45630
|
+
onnavi_request_close: e => {
|
|
45631
|
+
openController.requestClose(e, {
|
|
45632
|
+
isCancel: e.detail?.isCancel
|
|
45633
|
+
});
|
|
45014
45634
|
},
|
|
45015
45635
|
...rest,
|
|
45016
45636
|
children: [marker === false ? null : jsx("span", {
|
|
@@ -45046,24 +45666,9 @@ const ExpandableContent = ({
|
|
|
45046
45666
|
contentMounted,
|
|
45047
45667
|
hasAction,
|
|
45048
45668
|
effectiveAction,
|
|
45049
|
-
applyOpenedToContent,
|
|
45050
45669
|
contentContainerRef,
|
|
45051
45670
|
contentId
|
|
45052
45671
|
} = 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
45672
|
let content = children;
|
|
45068
45673
|
if (hasAction) {
|
|
45069
45674
|
content = jsx(ActionRenderer, {
|
|
@@ -54694,806 +55299,212 @@ const useInputGroup = (ref) => {
|
|
|
54694
55299
|
const allSelected =
|
|
54695
55300
|
active.selectionStart === 0 &&
|
|
54696
55301
|
active.selectionEnd === active.value.length;
|
|
54697
|
-
const atEnd =
|
|
54698
|
-
allSelected ||
|
|
54699
|
-
(active.selectionStart === active.value.length &&
|
|
54700
|
-
active.selectionEnd === active.value.length);
|
|
54701
|
-
if (!atEnd) {
|
|
54702
|
-
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) {
|
|
55302
|
+
const atEnd =
|
|
55303
|
+
allSelected ||
|
|
55304
|
+
(active.selectionStart === active.value.length &&
|
|
55305
|
+
active.selectionEnd === active.value.length);
|
|
55306
|
+
if (!atEnd) {
|
|
55307
|
+
return;
|
|
55308
|
+
}
|
|
55309
|
+
const inputs = getInputs();
|
|
55310
|
+
const idx = inputs.indexOf(active);
|
|
55311
|
+
if (idx === -1) {
|
|
55312
|
+
debugFocus(
|
|
55313
|
+
e,
|
|
55314
|
+
"InputGroup ArrowRight on non group input → do nothing",
|
|
55315
|
+
);
|
|
55316
|
+
return;
|
|
55317
|
+
}
|
|
55318
|
+
if (idx === inputs.length - 1) {
|
|
55319
|
+
debugFocus(
|
|
55320
|
+
e,
|
|
55321
|
+
"InputGroup ArrowRight at end of last input → do nothing",
|
|
55322
|
+
);
|
|
55323
|
+
return;
|
|
55324
|
+
}
|
|
55325
|
+
|
|
55326
|
+
debugFocus(
|
|
55327
|
+
e,
|
|
55328
|
+
"InputGroup ArrowRight at end of input[%d] → focus input[%d]",
|
|
55329
|
+
idx,
|
|
55330
|
+
idx + 1,
|
|
55331
|
+
);
|
|
55332
|
+
e.preventDefault();
|
|
55333
|
+
focusInput(inputs[idx + 1]);
|
|
55138
55334
|
return;
|
|
55139
55335
|
}
|
|
55140
|
-
|
|
55141
|
-
|
|
55336
|
+
const allSelected =
|
|
55337
|
+
active.selectionStart === 0 &&
|
|
55338
|
+
active.selectionEnd === active.value.length;
|
|
55339
|
+
const atStart =
|
|
55340
|
+
allSelected ||
|
|
55341
|
+
(active.selectionStart === 0 && active.selectionEnd === 0);
|
|
55342
|
+
if (!atStart) {
|
|
55142
55343
|
return;
|
|
55143
55344
|
}
|
|
55144
|
-
const
|
|
55145
|
-
|
|
55146
|
-
|
|
55147
|
-
|
|
55148
|
-
|
|
55149
|
-
|
|
55150
|
-
|
|
55151
|
-
|
|
55152
|
-
|
|
55153
|
-
|
|
55345
|
+
const inputs = getInputs();
|
|
55346
|
+
const idx = inputs.indexOf(active);
|
|
55347
|
+
if (idx === 0) {
|
|
55348
|
+
return;
|
|
55349
|
+
}
|
|
55350
|
+
debugFocus(
|
|
55351
|
+
e,
|
|
55352
|
+
"InputGroup ArrowLeft at start of input[%d] → focus input[%d]",
|
|
55353
|
+
idx,
|
|
55354
|
+
idx - 1,
|
|
55154
55355
|
);
|
|
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.
|
|
55356
|
+
e.preventDefault();
|
|
55357
|
+
focusInput(inputs[idx - 1]);
|
|
55358
|
+
};
|
|
55165
55359
|
|
|
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;
|
|
55360
|
+
const handleNaviInputFull = (e) => {
|
|
55361
|
+
if (!e.detail.event?.isTrusted) {
|
|
55362
|
+
// Programmatic value change (e.g. ArrowUp/Down) — don't auto-advance.
|
|
55363
|
+
return;
|
|
55246
55364
|
}
|
|
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;
|
|
55365
|
+
const input = e.detail.event.currentTarget;
|
|
55366
|
+
if (!el.contains(input)) {
|
|
55367
|
+
return;
|
|
55261
55368
|
}
|
|
55262
|
-
|
|
55263
|
-
|
|
55264
|
-
|
|
55265
|
-
close: (e = new CustomEvent("programmatic", { detail: {} }), detail) => {
|
|
55266
|
-
if (!controller.opened) {
|
|
55369
|
+
const inputs = getInputs();
|
|
55370
|
+
const idx = inputs.indexOf(input);
|
|
55371
|
+
if (idx === -1) {
|
|
55267
55372
|
return;
|
|
55268
55373
|
}
|
|
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
|
-
};
|
|
55374
|
+
if (idx === inputs.length - 1) {
|
|
55375
|
+
return;
|
|
55376
|
+
}
|
|
55377
|
+
const nextInput = inputs[idx + 1];
|
|
55378
|
+
debugFocus(
|
|
55379
|
+
e,
|
|
55380
|
+
"InputGroup navi_input_full on input -> move to next input",
|
|
55381
|
+
input,
|
|
55382
|
+
nextInput,
|
|
55383
|
+
);
|
|
55384
|
+
e.preventDefault();
|
|
55385
|
+
focusInput(nextInput);
|
|
55386
|
+
};
|
|
55288
55387
|
|
|
55289
|
-
//
|
|
55290
|
-
//
|
|
55291
|
-
// (
|
|
55292
|
-
//
|
|
55293
|
-
|
|
55294
|
-
|
|
55295
|
-
|
|
55296
|
-
|
|
55388
|
+
// const handlePaste = (e) => {
|
|
55389
|
+
// const active = document.activeElement;
|
|
55390
|
+
// if (!isTextInputElement(active) || !el.contains(active)) {
|
|
55391
|
+
// return;
|
|
55392
|
+
// }
|
|
55393
|
+
// const inputs = getInputs();
|
|
55394
|
+
// const startIdx = inputs.indexOf(active);
|
|
55395
|
+
// if (startIdx === -1) {
|
|
55396
|
+
// return;
|
|
55397
|
+
// }
|
|
55398
|
+
// const pastedText = e.clipboardData?.getData("text") ?? "";
|
|
55399
|
+
// if (!pastedText) {
|
|
55400
|
+
// return;
|
|
55401
|
+
// }
|
|
55402
|
+
// // Only intercept when the pasted text contains at least one separator
|
|
55403
|
+
// // from the inputs starting at the focused position.
|
|
55404
|
+
// const remainingInputs = inputs.slice(startIdx);
|
|
55405
|
+
// const hasSeparatorMatch = remainingInputs.some(
|
|
55406
|
+
// (input) =>
|
|
55407
|
+
// input.dataset.separator &&
|
|
55408
|
+
// pastedText.includes(input.dataset.separator),
|
|
55409
|
+
// );
|
|
55410
|
+
// if (!hasSeparatorMatch) {
|
|
55411
|
+
// return;
|
|
55412
|
+
// }
|
|
55413
|
+
// e.preventDefault();
|
|
55414
|
+
// let remaining = pastedText;
|
|
55415
|
+
// let lastFilledIdx = startIdx;
|
|
55416
|
+
// for (let i = 0; i < remainingInputs.length; i++) {
|
|
55417
|
+
// const input = remainingInputs[i];
|
|
55418
|
+
// const separator = input.dataset.separator;
|
|
55419
|
+
// let part;
|
|
55420
|
+
// if (separator && remaining.includes(separator)) {
|
|
55421
|
+
// const sepIdx = remaining.indexOf(separator);
|
|
55422
|
+
// part = remaining.slice(0, sepIdx);
|
|
55423
|
+
// remaining = remaining.slice(sepIdx + separator.length);
|
|
55424
|
+
// } else {
|
|
55425
|
+
// part = remaining;
|
|
55426
|
+
// remaining = "";
|
|
55427
|
+
// }
|
|
55428
|
+
// requestSubPaste(input, part, e);
|
|
55429
|
+
// lastFilledIdx = startIdx + i;
|
|
55430
|
+
// if (remaining === "") {
|
|
55431
|
+
// break;
|
|
55432
|
+
// }
|
|
55433
|
+
// }
|
|
55434
|
+
// focusInput(inputs[lastFilledIdx]);
|
|
55435
|
+
// };
|
|
55297
55436
|
|
|
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(() => {
|
|
55437
|
+
el.addEventListener("keydown", handleKeyDown, { capture: true });
|
|
55438
|
+
el.addEventListener("navi_input_full", handleNaviInputFull);
|
|
55439
|
+
// el.addEventListener("paste", handlePaste, { capture: true });
|
|
55315
55440
|
return () => {
|
|
55316
|
-
|
|
55441
|
+
el.removeEventListener("keydown", handleKeyDown, { capture: true });
|
|
55442
|
+
el.removeEventListener("navi_input_full", handleNaviInputFull);
|
|
55443
|
+
// el.removeEventListener("paste", handlePaste, { capture: true });
|
|
55317
55444
|
};
|
|
55318
|
-
}, []);
|
|
55319
|
-
return controllerRef.current;
|
|
55445
|
+
}, [debugFocus]);
|
|
55320
55446
|
};
|
|
55321
55447
|
|
|
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
|
-
};
|
|
55448
|
+
// const requestSubPaste = (input, value, event) => {
|
|
55449
|
+
// dispatchRequestInteraction(input, {
|
|
55450
|
+
// event,
|
|
55451
|
+
// name: "subpaste",
|
|
55452
|
+
// allowed: () => {
|
|
55453
|
+
// dispatchRequestSetUIState(input, value, { event });
|
|
55454
|
+
// },
|
|
55455
|
+
// });
|
|
55456
|
+
// };
|
|
55357
55457
|
|
|
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;
|
|
55458
|
+
const isTextInputElement = (el) => {
|
|
55459
|
+
if (!el) {
|
|
55460
|
+
return false;
|
|
55368
55461
|
}
|
|
55369
|
-
if (
|
|
55370
|
-
return
|
|
55462
|
+
if (el.tagName === "TEXTAREA") {
|
|
55463
|
+
return true;
|
|
55371
55464
|
}
|
|
55372
|
-
if (
|
|
55373
|
-
return
|
|
55465
|
+
if (el.tagName !== "INPUT") {
|
|
55466
|
+
return false;
|
|
55374
55467
|
}
|
|
55375
|
-
|
|
55468
|
+
const type = el.type || "text";
|
|
55469
|
+
return (
|
|
55470
|
+
type === "text" ||
|
|
55471
|
+
type === "search" ||
|
|
55472
|
+
type === "url" ||
|
|
55473
|
+
type === "tel" ||
|
|
55474
|
+
type === "email" ||
|
|
55475
|
+
type === "password" ||
|
|
55476
|
+
type === "number"
|
|
55477
|
+
);
|
|
55376
55478
|
};
|
|
55377
55479
|
|
|
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);
|
|
55480
|
+
// How many lines the thing around a component gives it, when that thing caps
|
|
55481
|
+
// its own height and cannot cap what it holds.
|
|
55482
|
+
//
|
|
55483
|
+
// A <Picker> is the case it exists for: its value is clamped with maxLines,
|
|
55484
|
+
// which is CSS line-clamp — it counts line boxes of inline text and has no idea
|
|
55485
|
+
// what a wrapped flex row is. So a <BadgeList> rendered as a picker's ui reads
|
|
55486
|
+
// the number from here and caps its own rows to it, and the picker turns its
|
|
55487
|
+
// own clamp off (see .navi_picker_value:has(.navi_badge_list) in picker.jsx).
|
|
55488
|
+
const MaxLinesContext = createContext(undefined);
|
|
55441
55489
|
|
|
55442
|
-
|
|
55443
|
-
|
|
55444
|
-
|
|
55490
|
+
// When a component render a prop that can be anything (js value of preact element)
|
|
55491
|
+
// 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
|
|
55492
|
+
const renderSafe = (value) => {
|
|
55493
|
+
if (
|
|
55494
|
+
value === null ||
|
|
55495
|
+
value === undefined ||
|
|
55496
|
+
typeof value === "string" ||
|
|
55497
|
+
typeof value === "number" ||
|
|
55498
|
+
typeof value === "boolean"
|
|
55499
|
+
) {
|
|
55500
|
+
return value;
|
|
55501
|
+
}
|
|
55445
55502
|
|
|
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
|
-
}
|
|
55503
|
+
if (isValidElement(value)) {
|
|
55504
|
+
return value;
|
|
55505
|
+
}
|
|
55469
55506
|
|
|
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]);
|
|
55507
|
+
return String(value);
|
|
55497
55508
|
};
|
|
55498
55509
|
|
|
55499
55510
|
/**
|
|
@@ -57039,10 +57050,10 @@ const useDialogProps = props => {
|
|
|
57039
57050
|
// the latest prop. Called by openController.open() before the content is
|
|
57040
57051
|
// built: what this popup opens ON is known before anything reads it.
|
|
57041
57052
|
openController.onOpen = onOpen || null;
|
|
57042
|
-
const
|
|
57043
|
-
children: childrenProp,
|
|
57053
|
+
const contentMounted = usePopupContentMount(openController, props.ref, {
|
|
57044
57054
|
mount
|
|
57045
57055
|
});
|
|
57056
|
+
const children = contentMounted ? childrenProp : null;
|
|
57046
57057
|
const isModal = layer === "top";
|
|
57047
57058
|
const ref = props.ref;
|
|
57048
57059
|
const expandY = Boolean(expand) || Boolean(expandYProp);
|
|
@@ -58484,10 +58495,10 @@ const usePopoverProps = props => {
|
|
|
58484
58495
|
// the latest prop. Called by openController.open() before the content is
|
|
58485
58496
|
// built: what this popup opens ON is known before anything reads it.
|
|
58486
58497
|
openController.onOpen = onOpen || null;
|
|
58487
|
-
const
|
|
58488
|
-
children: childrenProp,
|
|
58498
|
+
const contentMounted = usePopupContentMount(openController, props.ref, {
|
|
58489
58499
|
mount
|
|
58490
58500
|
});
|
|
58501
|
+
const children = contentMounted ? childrenProp : null;
|
|
58491
58502
|
const isTopLayer = layer === "top";
|
|
58492
58503
|
const ref = props.ref;
|
|
58493
58504
|
const backdropRef = useRef();
|