@jsenv/navi 0.29.324 → 0.29.326

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/README.md CHANGED
@@ -29,7 +29,7 @@ between related actions. Parent/child relations are first-class — `.one`,
29
29
 
30
30
  **`Box`** is the main layout primitive. It wraps CSS Flexbox with a friendlier API: `flex` for horizontal layout, `flex="y"` for vertical (no more guessing what `flex-direction: column` does visually). Supports `grid`, `inline`, alignment via `alignX`/`alignY`, and spacing props.
31
31
 
32
- **`createSlot`** renders content declared in one place at another: a toolbar, a status, or one `SidePanel` for a whole board that stays open as long as something fills it (`open={isFilled}`, see [docs/popup_open.md](./docs/popup_open.md)).
32
+ **`createSlot`** renders content declared in one place at another: a toolbar, a status, or one `SidePanel` for a whole board bound to the state that names what it shows (see [docs/popup_open.md](./docs/popup_open.md)).
33
33
 
34
34
  **`Text`** and related components (`Title`, `Paragraph`, `Code`, `Caption`) handle typography consistently across the app — including the parts of a line that are not text: icons, counts, truncation (`maxLines`), skeletons, and where a line may break. See [docs/typography.md](./docs/typography.md).
35
35
 
@@ -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, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, keepTouchRefusable, isPressDrivenClick, waitForTap, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, dragSourceThatStoodDown, markDragSource, refuseDragTo, startDragTo, installPanZoom, createEventGroupLogger, createInternalCustomEvent, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, isTouchDrivenEvent, scrollIntoViewScoped, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, initFocusGroup, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, getVirtualKeyboardOverlayHeight, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { isValidElement, createContext, render, h, toChildArray, options, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
@@ -3235,6 +3235,15 @@ const resolveActionProxies = (actionSet) => {
3235
3235
 
3236
3236
  const NO_PARAMS = { __no_params__: true };
3237
3237
  const mergeActionParams = (currentParams, newParams) => {
3238
+ if (newParams === null) {
3239
+ // `null` is a question that cannot be asked: a callback destructuring its
3240
+ // params throws on it. It is what a computed says when it has not got
3241
+ // enough to ask with, and "nothing to ask" is said with `undefined`
3242
+ // everywhere below (and by the run guards reading paramsSignal), so it
3243
+ // becomes that here, at the one door params coming from a binding go
3244
+ // through.
3245
+ newParams = undefined;
3246
+ }
3238
3247
  // The order of these two checks is load-bearing. Checking `undefined` first
3239
3248
  // looks symmetric — "no new params, keep whatever is there, NO_PARAMS
3240
3249
  // included" — and breaks routing: merge(NO_PARAMS, undefined) must yield
@@ -3492,7 +3501,10 @@ const createAction = (callback, rootOptions = {}) => {
3492
3501
  *
3493
3502
  * @param {any} newParamsOrSignal - params, or a signal holding them (the
3494
3503
  * result then retargets itself as the signal changes), or an object whose
3495
- * values may be signals.
3504
+ * values may be signals. A signal with nothing to ask yet — a question
3505
+ * computed from a form still missing a field — holds `undefined` or
3506
+ * `null`: the instance then has no params, which is what
3507
+ * `useAsyncData(action, { run: true })` reads to start nothing.
3496
3508
  * @param {object} [options]
3497
3509
  * @param {number} [options.debounce] - milliseconds the params must stay
3498
3510
  * stable before the instance follows them. What it buys is a screen that
@@ -13207,6 +13219,12 @@ const useExecuteAction = (
13207
13219
  // whose command sent — is stored as the callout display target, so the
13208
13220
  // message appears on whoever asked rather than on the whole control.
13209
13221
  const element = elementRef.current;
13222
+ if (!element) {
13223
+ // The control left the page while its run was out (its own answer can
13224
+ // unmount it, see the outcome events below): there is no box to hang
13225
+ // the refusal on. onActionError is still told, by the run's side effect.
13226
+ return;
13227
+ }
13210
13228
  let target = requester;
13211
13229
  // A requester that is no longer on the page is not a place to show
13212
13230
  // anything: the clear cross leaves with the value it optimistically
@@ -13310,12 +13328,20 @@ const useExecuteAction = (
13310
13328
  // Either three callbacks, one per outcome, or a single one for "however
13311
13329
  // this ends" — it receives `{ aborted, reason }`, `{ error }` or
13312
13330
  // `{ data }`, so a caller that only needs to know the action settled
13313
- // (and whether it worked) does not have to register three.
13331
+ // (and whether it worked) does not have to register three. Each also
13332
+ // receives the outcome event (navi_action_abort/error/end) as its
13333
+ // second argument.
13314
13334
  const addSideEffect = (sideEffect) => {
13315
13335
  if (typeof sideEffect === "function") {
13316
- addAbortCallback((reason) => sideEffect({ aborted: true, reason }));
13317
- addErrorCallback((error) => sideEffect({ error }));
13318
- addCompleteCallback((data) => sideEffect({ data }));
13336
+ addAbortCallback((reason, outcomeEvent) =>
13337
+ sideEffect({ aborted: true, reason }, outcomeEvent),
13338
+ );
13339
+ addErrorCallback((error, outcomeEvent) =>
13340
+ sideEffect({ error }, outcomeEvent),
13341
+ );
13342
+ addCompleteCallback((data, outcomeEvent) =>
13343
+ sideEffect({ data }, outcomeEvent),
13344
+ );
13319
13345
  return;
13320
13346
  }
13321
13347
  const { abort, error, complete } = sideEffect;
@@ -13323,56 +13349,6 @@ const useExecuteAction = (
13323
13349
  addErrorCallback(error);
13324
13350
  addCompleteCallback(complete);
13325
13351
  };
13326
- addSideEffect({
13327
- abort: (reason) => {
13328
- const element = elementRef.current;
13329
- if (
13330
- // at this stage the action side effect might have removed the <element> from the DOM
13331
- // (in theory no because action side effect are batched to happen after)
13332
- // but other side effects might do this
13333
- element
13334
- ) {
13335
- dispatchInternalCustomEvent(element, "navi_action_abort", {
13336
- ...sharedActionEventDetail,
13337
- reason,
13338
- });
13339
- }
13340
- },
13341
- error: (error) => {
13342
- if (errorEffect === "show_validation_message") {
13343
- addErrorMessage(error, { requester });
13344
- } else if (errorEffect === "throw") {
13345
- setError(error);
13346
- }
13347
-
13348
- const element = elementRef.current;
13349
- if (
13350
- // at this stage the action side effect might have removed the <element> from the DOM
13351
- // (in theory no because action side effect are batched to happen after)
13352
- // but other side effects might do this
13353
- element
13354
- ) {
13355
- dispatchInternalCustomEvent(element, "navi_action_error", {
13356
- ...sharedActionEventDetail,
13357
- error,
13358
- });
13359
- }
13360
- },
13361
- complete: (data) => {
13362
- const element = elementRef.current;
13363
- if (
13364
- // at this stage the action side effect might have removed the <element> from the DOM
13365
- // (in theory no because action side effect are batched to happen after)
13366
- // but other side effects might do this
13367
- element
13368
- ) {
13369
- dispatchInternalCustomEvent(element, "navi_action_end", {
13370
- ...sharedActionEventDetail,
13371
- data,
13372
- });
13373
- }
13374
- },
13375
- });
13376
13352
 
13377
13353
  const actionStartEventDetail = {
13378
13354
  ...sharedActionEventDetail,
@@ -13384,13 +13360,50 @@ const useExecuteAction = (
13384
13360
  actionStartEventDetail,
13385
13361
  );
13386
13362
 
13363
+ // The outcome is told twice: to the element, when there still is one,
13364
+ // and to the side effects registered at navi_action_start, always. A
13365
+ // run can outlive its control — the run's own answer, written to a
13366
+ // store the parent reads to decide what it draws, unmounts the control
13367
+ // one microtask before the run settles — and the element is then no
13368
+ // place to dispatch to: Preact drops a detached element's listeners.
13369
+ // The side effects are the run's, not the element's, so the event
13370
+ // reaches them either way.
13371
+ const tellOutcome = (outcomeEventName, outcomeEventDetail, trigger) => {
13372
+ const outcomeEvent = createInternalCustomEvent(outcomeEventName, {
13373
+ ...sharedActionEventDetail,
13374
+ ...outcomeEventDetail,
13375
+ });
13376
+ const element = elementRef.current;
13377
+ if (element) {
13378
+ element.dispatchEvent(outcomeEvent);
13379
+ }
13380
+ trigger(outcomeEvent);
13381
+ };
13382
+
13387
13383
  const runAction = () => {
13388
13384
  return action[method]({
13389
13385
  event: actionEvent,
13390
13386
  reason: `"${event.type}" event on ${getElementSignature(event.target)}`,
13391
- onAbort: triggerAbort,
13392
- onError: triggerError,
13393
- onComplete: triggerComplete,
13387
+ onAbort: (reason) => {
13388
+ tellOutcome("navi_action_abort", { reason }, (outcomeEvent) => {
13389
+ triggerAbort(reason, outcomeEvent);
13390
+ });
13391
+ },
13392
+ onError: (error) => {
13393
+ if (errorEffect === "show_validation_message") {
13394
+ addErrorMessage(error, { requester });
13395
+ } else if (errorEffect === "throw") {
13396
+ setError(error);
13397
+ }
13398
+ tellOutcome("navi_action_error", { error }, (outcomeEvent) => {
13399
+ triggerError(error, outcomeEvent);
13400
+ });
13401
+ },
13402
+ onComplete: (data) => {
13403
+ tellOutcome("navi_action_end", { data }, (outcomeEvent) => {
13404
+ triggerComplete(data, outcomeEvent);
13405
+ });
13406
+ },
13394
13407
  });
13395
13408
  };
13396
13409
 
@@ -17100,6 +17113,11 @@ const generateSignalId = () => {
17100
17113
  * as the user moved. `"push"` is for a state whose values ARE places one came
17101
17114
  * from (the photo one is looking at in a gallery). Whatever is said here, one
17102
17115
  * write can say otherwise: `signal.set(value, { history })`.
17116
+ * Either way every write reaches the address, synchronously, and browsers
17117
+ * refuse an address written too often (Safari: 100 writes per 10 s, then a
17118
+ * SecurityError). A value one DRAGS is written at 60 Hz: keep it in the
17119
+ * gesture while the finger is down and write the state once, on release —
17120
+ * an address is not a recording of a gesture.
17103
17121
  * @param {boolean} [options.debug=false] - Enable debug logging for this signal's operations
17104
17122
  * @returns {import("@preact/signals").Signal} A signal that can be synchronized with a source signal and/or persisted in localStorage. The signal includes a `validity` property for validation state.
17105
17123
  *
@@ -27293,11 +27311,7 @@ const setupBrowserIntegrationViaHistory = ({
27293
27311
  [NAV_DEPTH_STATE_KEY]: getNavDepth(),
27294
27312
  },
27295
27313
  });
27296
- if (navigationType === "push") {
27297
- window.history.pushState(effectiveState, null, url);
27298
- } else {
27299
- window.history.replaceState(effectiveState, null, url);
27300
- }
27314
+ writeHistoryEntry(navigationType, effectiveState, url);
27301
27315
  rememberEntryIsOfThisDocument();
27302
27316
  updateDocumentUrl(url);
27303
27317
  updateDocumentState(effectiveState);
@@ -27317,14 +27331,14 @@ const setupBrowserIntegrationViaHistory = ({
27317
27331
  [NAV_DEPTH_STATE_KEY]: getNavDepth(),
27318
27332
  },
27319
27333
  });
27320
- window.history.replaceState(state, null, url);
27334
+ writeHistoryEntry("replace", state, url);
27321
27335
  rememberEntryIsOfThisDocument();
27322
27336
  } else if (redirected) {
27323
27337
  // The entry the browser is on names an address that only sends
27324
27338
  // elsewhere — a cold load on it, or a back into it. Written over where
27325
27339
  // it stands (the entry keeps its place in the stack, hence its state
27326
27340
  // and the depth in it): pressing back must not walk into it again.
27327
- window.history.replaceState(state, null, url);
27341
+ writeHistoryEntry("replace", state, url);
27328
27342
  rememberEntryIsOfThisDocument();
27329
27343
  }
27330
27344
  updateDocumentUrl(url);
@@ -27577,7 +27591,7 @@ const setupBrowserIntegrationViaHistory = ({
27577
27591
  // The entry itself has to lose them too, not just the document state:
27578
27592
  // getDocumentState() reads the entry back, and every state write copies
27579
27593
  // what it finds there onto the next one.
27580
- window.history.replaceState(state, null, url);
27594
+ writeHistoryEntry("replace", state, url);
27581
27595
  }
27582
27596
  handleRoutingTask(url, {
27583
27597
  reason: "routing initialization",
@@ -27604,6 +27618,57 @@ const setupBrowserIntegrationViaHistory = ({
27604
27618
  // replace onto the url already displayed. Never a traverse — the browser has
27605
27619
  // already moved window.location.href when a popstate is handled, so the
27606
27620
  // comparison would say "same" about every back and forward.
27621
+ // Browsers refuse an address written too often: WebKit throws a SecurityError
27622
+ // past 100 writes per 10 seconds, Chromium and Firefox drop the write. A state
27623
+ // bound to the url and written per frame gets there in under two seconds, and
27624
+ // a developer working in Chromium never sees it. Every write goes through here
27625
+ // so the rate is measured against the strictest budget before any browser
27626
+ // refuses, and so a refusal names its cause rather than `replaceState`.
27627
+ const HISTORY_WRITE_BUDGET = 100;
27628
+ const HISTORY_WRITE_WINDOW_MS = 10_000;
27629
+ const ADDRESS_WRITE_ADVICE = `An address is not a recording of a gesture: a value dragged per frame belongs to the gesture, and the state bound to the url is written once, on release.`;
27630
+ const writeHistoryEntry = (navigationType, state, url) => {
27631
+ noteHistoryWrite(url);
27632
+ try {
27633
+ if (navigationType === "push") {
27634
+ window.history.pushState(state, null, url);
27635
+ } else {
27636
+ window.history.replaceState(state, null, url);
27637
+ }
27638
+ } catch (e) {
27639
+ if (e.name !== "SecurityError") {
27640
+ throw e;
27641
+ }
27642
+ throw new Error(
27643
+ `The browser refused to write the address ${url}: ${e.message}. ${ADDRESS_WRITE_ADVICE}`,
27644
+ { cause: e },
27645
+ );
27646
+ }
27647
+ };
27648
+ const noteHistoryWrite = (() => {
27649
+ const writeTimes = [];
27650
+ let warned = false;
27651
+ return (url) => {
27652
+ if (warned) {
27653
+ return;
27654
+ }
27655
+ const now = performance.now();
27656
+ writeTimes.push(now);
27657
+ while (writeTimes[0] < now - HISTORY_WRITE_WINDOW_MS) {
27658
+ writeTimes.shift();
27659
+ }
27660
+ if (writeTimes.length < HISTORY_WRITE_BUDGET) {
27661
+ return;
27662
+ }
27663
+ warned = true;
27664
+ const seconds = ((now - writeTimes[0]) / 1000).toFixed(1);
27665
+ console.warn(
27666
+ `[navi] the address was written ${HISTORY_WRITE_BUDGET} times in ${seconds}s (last: ${url}). Safari refuses more than ${HISTORY_WRITE_BUDGET} writes per ${HISTORY_WRITE_WINDOW_MS / 1000}s with a SecurityError, and the state then runs ahead of the address. ${ADDRESS_WRITE_ADVICE}`,
27667
+ );
27668
+ };
27669
+ })()
27670
+ ;
27671
+
27607
27672
  const isStateOnlyNavigation = (
27608
27673
  url,
27609
27674
  { navigationType },
@@ -40910,10 +40975,24 @@ const useInteractiveProps = (props, {
40910
40975
  // aborted run, whose promise is awaited to completion (see
40911
40976
  // performRun in actions.js) — which is exactly what "the server is
40912
40977
  // done with it" means, and therefore when the queued request may go.
40913
- e.detail.addSideEffect(outcome => {
40978
+ e.detail.addSideEffect((outcome, outcomeEvent) => {
40914
40979
  uiStateController.actionInFlight = false;
40915
40980
  uiStateController.runningActionSignal.value = null;
40916
40981
  uiStateController.parallelGuard?.release(uiStateController);
40982
+ // The outcome callbacks are told from here, the run's side effect,
40983
+ // and not from the onnavi_action_* handlers below: the run can
40984
+ // outlive this control (its own answer, written to a store the
40985
+ // parent reads, unmounts it one microtask before it settles), and
40986
+ // whoever wears the run — a parent's `loading` following these,
40987
+ // see 12_picker_card_demo.html — must hear it end all the same.
40988
+ // The handlers below keep what needs the element.
40989
+ if (outcome.aborted) {
40990
+ onActionAborted?.(outcomeEvent);
40991
+ } else if ("error" in outcome) {
40992
+ onActionError?.(outcome.error, outcomeEvent);
40993
+ } else {
40994
+ onActionEnd?.(outcome.data, outcomeEvent);
40995
+ }
40917
40996
  const queuedEvent = uiStateController.queuedActionAllowedEvent;
40918
40997
  if (!queuedEvent) {
40919
40998
  syncOptimisticAttribute(false);
@@ -40953,7 +41032,6 @@ const useInteractiveProps = (props, {
40953
41032
  if (resetOnAbort && !superseded) {
40954
41033
  dispatchRequestResetUIState(e.currentTarget, e);
40955
41034
  }
40956
- onActionAborted?.(e);
40957
41035
  },
40958
41036
  onnavi_action_error: e => {
40959
41037
  const {
@@ -40963,7 +41041,6 @@ const useInteractiveProps = (props, {
40963
41041
  if (resetOnError) {
40964
41042
  dispatchRequestResetUIState(e.currentTarget, e);
40965
41043
  }
40966
- onActionError?.(error, e);
40967
41044
  uiStateController.onActionError(e);
40968
41045
  },
40969
41046
  onnavi_action_end: e => {
@@ -40971,7 +41048,6 @@ const useInteractiveProps = (props, {
40971
41048
  data
40972
41049
  } = e.detail;
40973
41050
  debugAction(e, `action end with data: ${JSON.stringify(data)}`);
40974
- onActionEnd?.(data, e);
40975
41051
  controlRootProps.onnavi_action_end?.(e);
40976
41052
  uiStateController.onActionEnd(e);
40977
41053
 
@@ -60451,34 +60527,44 @@ const css$E = /* css */`
60451
60527
  * `showModal()`'d (native focus trap, `Escape`-to-cancel, hardware
60452
60528
  * back-button dismissal, rest-of-document made inert), without one it is
60453
60529
  * shown through the Popover API and the page behind stays live.
60454
- * @param {boolean} [props.dockedOnSmallTouchScreen] - Turns the dialog into a
60455
- * bottom sheet (docked flush to the bottom edge, full width) on a small touch
60456
- * screen, and leaves it alone otherwise. For a dialog meant to be interacted
60457
- * with rather than merely read: on a phone the keyboard owns the bottom of
60458
- * the screen and a centered box ends up both cramped and out of thumb reach,
60459
- * while under a mouse the centered box is already the right shape. Both
60460
- * halves of the name matter (`smallTouchScreenSignal`): touch alone would
60461
- * dock a big touch screen — a tablet, a kiosk panel — a whole screen away
60462
- * from where the finger just tapped, and size alone would dock a narrow
60463
- * desktop window, which is still a mouse. It supplies defaults for
60464
- * `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`, so
60465
- * any of them can still be pinned explicitly — including `expandX={false}`,
60530
+ * @param {boolean|"top"|"bottom"} [props.dockedOnSmallTouchScreen] - Turns
60531
+ * the dialog into a sheet (docked flush to one edge, full width) on a small
60532
+ * touch screen, and leaves it alone otherwise: under a mouse the centered box
60533
+ * is already the right shape, while on a phone a centered box ends up cramped
60534
+ * between the keyboard and the top of the screen. The value says which edge
60535
+ * the sheet rests on. `true` (or `"top"`) rests it on the top edge, out of
60536
+ * the virtual keyboard's way: a field tapped in the sheet raises the keyboard
60537
+ * below it, and the question and the field stay where the finger found them.
60538
+ * `"bottom"` rests it on the bottom edge, where the thumbs are — for a sheet
60539
+ * that is read and tapped (a list, a couple of buttons, a confirmation) and
60540
+ * holds nothing that raises a keyboard; one that does is reflowed into the
60541
+ * strip left above the keyboard at every keystroke. The edge is said here
60542
+ * rather than through `positionArea`, which places both shapes:
60543
+ * `positionArea="bottom"` would also pin the centered box to the bottom of a
60544
+ * desktop window. Both halves of the name matter (`smallTouchScreenSignal`):
60545
+ * touch alone would dock a big touch screen — a tablet, a kiosk panel — a
60546
+ * whole screen away from where the finger just tapped, and size alone would
60547
+ * dock a narrow desktop window, which is still a mouse. It supplies defaults
60548
+ * for `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`,
60549
+ * so any of them can still be pinned explicitly — including `expandX={false}`,
60466
60550
  * which opts the docked dialog out of the full-width stretch and leaves it a
60467
- * floating box at the bottom. It also withdraws `maxWidth` while docked: a
60551
+ * floating box against its edge. It also withdraws `maxWidth` while docked: a
60468
60552
  * sheet is container-wide by definition, and a `maxWidth` is an answer about
60469
60553
  * the *centered* shape, so the two can be stated together (`maxWidth="16rem"
60470
60554
  * dockedOnSmallTouchScreen`) and each applies where it means something.
60471
60555
  * `minWidth` needs no such rule — its floor is below the full width — and
60472
60556
  * `maxHeight`/`minHeight` keep applying, a sheet being content-tall.
60473
- * Ignored entirely when `expandY`
60474
- * (or `expand`) is set: a dialog already filling the height is on the bottom
60475
- * edge docking would bring it to, so docking could only take away the shape
60476
- * the caller asked for. Re-resolves live as the pointer
60477
- * type or the window size changes. A sheet resting on the bottom edge is also
60478
- * pushed back down to close it, held by its header (a `Box` with the `header`
60479
- * prop) and by anything else carrying `data-swipe-grip`. The rest of the sheet
60480
- * is left to what it holds, so a board something is dragged across keeps its
60481
- * own gestures. See `swipe_to_close.js`.
60557
+ * Ignored entirely when `expandY` (or `expand`) is set: a dialog already
60558
+ * filling the height touches both edges, so docking could only take away the
60559
+ * shape the caller asked for. Re-resolves live as the pointer type or the
60560
+ * window size changes. A sheet is also pushed back through the edge it rests
60561
+ * on to close it (down for a bottom sheet, up for a top one), held by its
60562
+ * header (a `Box` with the `header` prop) and by anything else carrying
60563
+ * `data-swipe-grip`. The rest of the sheet is left to what it holds, so a
60564
+ * board something is dragged across keeps its own gestures. See
60565
+ * `swipe_to_close.js`. In dev, a `"bottom"` sheet found holding a field that
60566
+ * raises the keyboard is warned about at open, on any screen — the mistake
60567
+ * itself only shows on a phone.
60482
60568
  * @param {string} [props.positionArea="center"] - Where to dock the dialog
60483
60569
  * within its container (the viewport for `layer="top"`, the positioned
60484
60570
  * ancestor for `layer="local"`) — Dialog is never anchored to a real
@@ -60846,28 +60932,28 @@ const DialogLocal = props => {
60846
60932
  * own to render: a modal's is the native `::backdrop`, and `backdrop={false}`
60847
60933
  * has none at all.
60848
60934
  */
60849
- // What a dialog turns into on a small touch screen. "bottom" is not a taste:
60850
- // it puts the dialog in the zone a phone is actually operated from — where the
60851
- // thumbs rest and where the virtual keyboard comes up — instead of the middle
60852
- // of the screen, which is the farthest point from both.
60935
+ // What a dialog turns into on a small touch screen: a sheet flush against one
60936
+ // edge of the screen. Which edge is the caller's word (the prop's value, see
60937
+ // its JSDoc), the top one unless told "bottom": the cheap mistake must be the
60938
+ // default one — a sheet docked top by mistake is a little farther from the
60939
+ // thumb, a sheet docked bottom by mistake fights the virtual keyboard, which
60940
+ // rises from that very edge, on every keystroke.
60853
60941
  // Only defaults: an explicitly passed prop still wins, so the docked shape can
60854
60942
  // be adjusted one axis at a time instead of being all-or-nothing.
60855
60943
  const DOCKED = {
60856
- positionArea: "bottom",
60857
60944
  marginWithContainer: 0,
60858
60945
  expandX: true,
60859
- // A sheet resting on the bottom edge is dragged with a thumb, and a drag that
60860
- // runs past its own edge must not land on the page behind it: the same
60861
- // reasoning as "bottom" above, applied to the gesture instead of the shape.
60946
+ // A sheet is dragged with a thumb, and a drag that runs past its own edge
60947
+ // must not land on the page behind it.
60862
60948
  scrollCapture: true
60863
60949
  };
60864
60950
 
60865
- // Where a bottom sheet is held to push it back down: the strip a Box declares
60866
- // with `header`, plus anything the application marked as one more. Everything
60867
- // else in the sheet is content the finger came to operate — a board a piece is
60868
- // dragged across, a list, a map — and a press there belongs to it. A sheet with
60869
- // no header and nothing marked is not pushed down at all; it is closed by its
60870
- // own controls, by the backdrop and by Escape.
60951
+ // Where a sheet is held to push it back through its edge: the strip a Box
60952
+ // declares with `header`, plus anything the application marked as one more.
60953
+ // Everything else in the sheet is content the finger came to operate — a board
60954
+ // a piece is dragged across, a list, a map — and a press there belongs to it. A
60955
+ // sheet with no header and nothing marked is not pushed back at all; it is
60956
+ // closed by its own controls, by the backdrop and by Escape.
60871
60957
  const DOCKED_SWIPE_GRIP = "[data-header],[data-swipe-grip]";
60872
60958
  const useDialogProps = props => {
60873
60959
  const backdropProps = {};
@@ -60973,12 +61059,15 @@ const useDialogProps = props => {
60973
61059
  // Only a small touch screen changes anything: on a mouse — and on a touch
60974
61060
  // screen too big to reach the bottom edge of — a dialog already wants to be
60975
61061
  // the centered box it is by default, so there is nothing to resolve.
60976
- // expandY cancels the docking outright: docking exists to bring the dialog
60977
- // down to the edge the thumb is on, and a dialog already filling the height
60978
- // is on that edge — all docking could still do is take away the shape the
60979
- // caller asked for (and arm a swipe-down on something that never rose).
61062
+ // expandY cancels the docking outright: a dialog already filling the height
61063
+ // touches both edges — all docking could still do is take away the shape
61064
+ // the caller asked for (and arm a swipe on something that never rose).
60980
61065
  const isDocked = dockedOnSmallTouchScreen && smallTouchScreenSignal.value && !expandY;
60981
- const positionArea = positionAreaProp ?? (isDocked ? DOCKED.positionArea : "center");
61066
+ const dockedEdge = dockedOnSmallTouchScreen === "bottom" ? "bottom" : "top";
61067
+ const positionArea = positionAreaProp ?? (isDocked ? dockedEdge : "center");
61068
+ // Read off the shape a phone would give, not the one this screen gives: the
61069
+ // author of a bottom sheet tests on a desktop, where the mistake is silent.
61070
+ const sheetRestsOnBottom = Boolean(dockedOnSmallTouchScreen) && !expandY && restsOnBottom(positionAreaProp ?? dockedEdge);
60982
61071
  const marginWithContainer = marginWithContainerProp ?? (isDocked ? DOCKED.marginWithContainer :
60983
61072
  // A share of whatever holds the dialog: the app's own screen for a
60984
61073
  // top-layer one — where appw is exactly "3% of the container", the
@@ -61040,12 +61129,12 @@ const useDialogProps = props => {
61040
61129
  y: "center",
61041
61130
  x: "center"
61042
61131
  };
61043
- // Pushing the sheet back down closes it — a bottom sheet is reached with a
61044
- // thumb, and the thumb is already on the edge it would push. Only a sheet
61045
- // actually resting on the bottom edge: anywhere else the gesture would send
61046
- // the dialog somewhere it never came from.
61047
- const swipeToCloseDown = isDocked && parsedPositionArea.y === "bottom";
61048
- const onSwipePointerDown = swipeToCloseDown ? createSwipeToClose("bottom", {
61132
+ // Pushing the sheet back through the edge it rests on closes it — the way
61133
+ // it came in, and the way the thumb on that edge already pushes. Only a sheet
61134
+ // actually resting on an edge: anywhere else the gesture would send the
61135
+ // dialog somewhere it never came from.
61136
+ const swipeToCloseSide = isDocked && (parsedPositionArea.y === "top" || parsedPositionArea.y === "bottom") ? parsedPositionArea.y : null;
61137
+ const onSwipePointerDown = swipeToCloseSide ? createSwipeToClose(swipeToCloseSide, {
61049
61138
  grip: DOCKED_SWIPE_GRIP
61050
61139
  }) : null;
61051
61140
  // A corner sitting exactly on the container's own corner must not be
@@ -61173,6 +61262,9 @@ const useDialogProps = props => {
61173
61262
  }
61174
61263
  {
61175
61264
  warnAboutUnreachableOutsideRegions(dialogEl);
61265
+ if (sheetRestsOnBottom) {
61266
+ warnBottomSheetHoldingKeyboardControl(dialogEl);
61267
+ }
61176
61268
  }
61177
61269
 
61178
61270
  // Set by useOpenControllerByProps for the very first open triggered by
@@ -61721,12 +61813,12 @@ const useDialogProps = props => {
61721
61813
  "data-flush-right": flushEdges.right ? "" : undefined,
61722
61814
  "data-flush-bottom": flushEdges.bottom ? "" : undefined,
61723
61815
  "data-flush-left": flushEdges.left ? "" : undefined,
61724
- "data-swipe-to-close": swipeToCloseDown ? "" : undefined,
61816
+ "data-swipe-to-close": swipeToCloseSide ?? undefined,
61725
61817
  // The axis the sheet travels on when it is pushed back, said to the shared
61726
61818
  // gesture layer: it is what a box travelling inside the sheet reads to know
61727
61819
  // this axis is already walked (see @jsenv/dom's drag_to_travel).
61728
- "data-drag-travel": swipeToCloseDown ? SWIPE_AXIS_BY_SIDE.bottom : undefined,
61729
- "data-travel-by-drag": swipeToCloseDown ? SWIPE_AXIS_BY_SIDE.bottom : undefined,
61820
+ "data-drag-travel": swipeToCloseSide ? SWIPE_AXIS_BY_SIDE[swipeToCloseSide] : undefined,
61821
+ "data-travel-by-drag": swipeToCloseSide ? SWIPE_AXIS_BY_SIDE[swipeToCloseSide] : undefined,
61730
61822
  "onPointerDown": e => {
61731
61823
  rest.onPointerDown?.(e);
61732
61824
  onSwipePointerDown?.(e);
@@ -61798,6 +61890,33 @@ const useDialogProps = props => {
61798
61890
  }
61799
61891
  return [!isTopLayer && backdrop ? backdropProps : null, contentProps];
61800
61892
  };
61893
+ const restsOnBottom = positionArea => {
61894
+ const parsed = parsePositionArea(positionArea);
61895
+ return Boolean(parsed) && parsed.y === "bottom";
61896
+ };
61897
+
61898
+ // What raises a virtual keyboard when tapped: a text-like input, a textarea,
61899
+ // anything editable. Not a `select` — it opens a menu, which takes no screen
61900
+ // from the sheet — and not a read-only field.
61901
+ const KEYBOARD_RAISING_CONTROL_SELECTOR = `
61902
+ input:not([type]):not([readonly]),
61903
+ input[type="text"]:not([readonly]),
61904
+ input[type="search"]:not([readonly]),
61905
+ input[type="email"]:not([readonly]),
61906
+ input[type="url"]:not([readonly]),
61907
+ input[type="tel"]:not([readonly]),
61908
+ input[type="password"]:not([readonly]),
61909
+ input[type="number"]:not([readonly]),
61910
+ textarea:not([readonly]),
61911
+ [contenteditable]:not([contenteditable="false"])
61912
+ `;
61913
+ const warnBottomSheetHoldingKeyboardControl = dialogEl => {
61914
+ const controlEl = dialogEl.querySelector(KEYBOARD_RAISING_CONTROL_SELECTOR);
61915
+ if (!controlEl) {
61916
+ return;
61917
+ }
61918
+ console.warn(`[navi] a Dialog docked to the bottom edge (dockedOnSmallTouchScreen="bottom") holds a field that raises the virtual keyboard. On a phone the keyboard rises from the edge the sheet rests on and reflows the sheet into the strip left above it, at every keystroke. A sheet one types into rests on the top edge: dockedOnSmallTouchScreen without a value. "bottom" is for a sheet one reads and taps.`, controlEl);
61919
+ };
61801
61920
  const DIALOG_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible", ":focus-within"];
61802
61921
 
61803
61922
  // Lets consumers pass animationDuration="0.5s" as a regular prop; Box maps
@@ -63529,8 +63648,8 @@ const css$C = /* css */`@layer navi {
63529
63648
  * @param {string} [props.positionAreaFixed] - **Popover-only**, same guard.
63530
63649
  * @param {string} [props.positionAreaWhenAnchorIsInvalid] - **Popover-only**,
63531
63650
  * same guard.
63532
- * @param {boolean} [props.dockedOnSmallTouchScreen] - **Dialog-only** (a
63533
- * popover is never a bottom sheet), destructured out for the same reason
63651
+ * @param {boolean|"top"|"bottom"} [props.dockedOnSmallTouchScreen] -
63652
+ * **Dialog-only** (a popover is never a sheet), destructured out for the same reason
63534
63653
  * turned around: it must not land on the popover element as a stray DOM
63535
63654
  * attribute when the screen-size resolution picks `mode="popover"`. Where
63536
63655
  * that resolution lands is what decides whether it applies at all, so a
@@ -63558,7 +63677,7 @@ const css$C = /* css */`@layer navi {
63558
63677
  * honoured in either mode — a wall-less `Dialog` is shown through the
63559
63678
  * Popover API rather than `showModal()` — so which mode the screen-size
63560
63679
  * resolution picks says nothing about whether the page behind stays live.
63561
- * It is also what makes a sheet docked to the bottom of a phone's screen
63680
+ * It is also what makes a sheet docked to an edge of a phone's screen
63562
63681
  * (`dockedOnSmallTouchScreen`) non-modal.
63563
63682
  * @param {"auto"|"discrete"|"invisible"} [props.backdropVariant] - Forwarded
63564
63683
  * as-is to whichever component renders (both understand it identically):
@@ -76813,7 +76932,7 @@ const css$n = /* css */`.navi_split_button {
76813
76932
  * dialogExpand?: boolean,
76814
76933
  * dialogExpandX?: boolean,
76815
76934
  * dialogExpandY?: boolean,
76816
- * dockedOnSmallTouchScreen?: boolean,
76935
+ * dockedOnSmallTouchScreen?: boolean | "top" | "bottom",
76817
76936
  * marginWithContainer?: number | string,
76818
76937
  * backdrop?: boolean,
76819
76938
  * backdropVariant?: "auto" | "discrete" | "invisible",
@@ -86249,6 +86368,9 @@ const createSlot = (SlotRenderer = Box) => {
86249
86368
  slotPropsSignal.value = props;
86250
86369
  useLayoutEffect(() => {
86251
86370
  return () => {
86371
+ // Within one diff preact mounts the newcomer before unmounting the
86372
+ // leaver, so the leaver may no longer be the filler by the time it
86373
+ // is cleaned up.
86252
86374
  if (filler !== fillerRef) {
86253
86375
  return;
86254
86376
  }