@jsenv/navi 0.29.59 → 0.29.61

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.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { coarsePointerSignal } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getElementSignature, findEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
7
+ import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
8
8
  export { contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, batch, untracked, useSignal } from "@preact/signals";
10
10
  import { createContext, isValidElement, h, Fragment, render, toChildArray, options, cloneElement } from "preact";
@@ -7924,6 +7924,9 @@ const css$$ = /* css */`
7924
7924
  * @param {string} [options.status=""] - Callout status: "info" | "warning" | "error" | "success"
7925
7925
  * @param {Function} [options.onClose] - Callback when callout is closed
7926
7926
  * @param {boolean} [options.closeOnClickOutside] - Whether to close on outside clicks (defaults to true for "info" status)
7927
+ * @param {string} [options.reopen="toggle"] - What to do when the anchor already has an open callout:
7928
+ * "toggle" closes it (a second press on what opened it closes it), "update" replaces its message
7929
+ * in place, "replace" tears it down and opens a new one
7927
7930
  * @param {boolean} [options.debug=false] - Enable debug logging
7928
7931
  *
7929
7932
  * Positioning is also driven by attributes read on the anchor element itself
@@ -7964,6 +7967,7 @@ const openCallout = (message, {
7964
7967
  closeOnClickOutside = status === "info",
7965
7968
  closeOnFocusLeave = closeOnClickOutside,
7966
7969
  openingEvent,
7970
+ reopen = "toggle",
7967
7971
  showErrorStack,
7968
7972
  skipFocus = false,
7969
7973
  debug = () => {}
@@ -7972,6 +7976,45 @@ const openCallout = (message, {
7972
7976
  if (debug === true) {
7973
7977
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
7974
7978
  }
7979
+ const originalAnchorElement = anchorElement;
7980
+ if (anchorElement) {
7981
+ const proxyElement = findControlProxy(anchorElement);
7982
+ if (proxyElement) {
7983
+ anchorElement = proxyElement;
7984
+ }
7985
+ const controlRoot = findControlRoot(anchorElement);
7986
+ if (controlRoot) {
7987
+ anchorElement = controlRoot;
7988
+ }
7989
+ const anchorVisuallyVisibleInfo = getVisuallyVisibleInfo(anchorElement, {
7990
+ countOffscreenAsVisible: true
7991
+ });
7992
+ if (!anchorVisuallyVisibleInfo.visible) {
7993
+ anchorElement = getFirstVisuallyVisibleAncestor(anchorElement);
7994
+ if (!anchorElement) {
7995
+ // anchorElement is not in the DOM anymore, fallback to body
7996
+ anchorElement = document.body;
7997
+ }
7998
+ console.warn(`anchor is not visually visible (${anchorVisuallyVisibleInfo.reason}) -> callout will anchor to first visually visible ancestor (${getElementSignature(anchorElement)})`);
7999
+ }
8000
+ }
8001
+ if (anchorElement) {
8002
+ const calloutAlreadyThere = anchorElement.callout;
8003
+ if (calloutAlreadyThere && calloutAlreadyThere.opened) {
8004
+ if (reopen === "toggle") {
8005
+ debug(openingEvent, `callout already open on ${getElementSignature(anchorElement)} -> close it (reopen: "toggle")`);
8006
+ calloutAlreadyThere.requestClose(openingEvent, "reopen_toggle");
8007
+ return calloutAlreadyThere;
8008
+ }
8009
+ if (reopen === "update") {
8010
+ debug(openingEvent, `callout already open on ${getElementSignature(anchorElement)} -> update it (reopen: "update")`);
8011
+ calloutAlreadyThere.update(message, {
8012
+ status
8013
+ });
8014
+ return calloutAlreadyThere;
8015
+ }
8016
+ }
8017
+ }
7975
8018
  const callout = {
7976
8019
  opened: true,
7977
8020
  close: null,
@@ -8155,28 +8198,7 @@ const openCallout = (message, {
8155
8198
  callout.updatePosition();
8156
8199
  }
8157
8200
  };
8158
- const originalAnchorElement = anchorElement;
8159
- if (anchorElement) {
8160
- const proxyElement = findControlProxy(anchorElement);
8161
- if (proxyElement) {
8162
- anchorElement = proxyElement;
8163
- }
8164
- const controlRoot = findControlRoot(anchorElement);
8165
- if (controlRoot) {
8166
- anchorElement = controlRoot;
8167
- }
8168
- const anchorVisuallyVisibleInfo = getVisuallyVisibleInfo(anchorElement, {
8169
- countOffscreenAsVisible: true
8170
- });
8171
- if (!anchorVisuallyVisibleInfo.visible) {
8172
- anchorElement = getFirstVisuallyVisibleAncestor(anchorElement);
8173
- if (!anchorElement) {
8174
- // anchorElement is not in the DOM anymore, fallback to body
8175
- anchorElement = document.body;
8176
- }
8177
- console.warn(`anchor is not visually visible (${anchorVisuallyVisibleInfo.reason}) -> callout will anchor to first visually visible ancestor (${getElementSignature(anchorElement)})`);
8178
- }
8179
- }
8201
+
8180
8202
  // Resolve the visual anchor for positioning: when data-callout-anchor is set,
8181
8203
  // use the inner element it points to. anchorElement remains the container
8182
8204
  // that receives data-callout and CSS vars.
@@ -8212,6 +8234,14 @@ const openCallout = (message, {
8212
8234
  return anchorElement.parentNode || document.body;
8213
8235
  })();
8214
8236
  {
8237
+ // document.body as anchor means "no anchor" (the callout is docked in the
8238
+ // viewport); everything would be inside it.
8239
+ const isInsideAnchor = target => {
8240
+ if (!anchorElement || anchorElement === document.body) {
8241
+ return false;
8242
+ }
8243
+ return anchorElement === target || anchorElement.contains(target);
8244
+ };
8215
8245
  const handleClickOutside = event => {
8216
8246
  if (event.button !== 0) {
8217
8247
  // right click
@@ -8221,6 +8251,16 @@ const openCallout = (message, {
8221
8251
  if (clickTarget === calloutElement || calloutElement.contains(clickTarget)) {
8222
8252
  return;
8223
8253
  }
8254
+ if (isInsideAnchor(clickTarget)) {
8255
+ // Pressing the anchor is not "outside": this listener is on document in
8256
+ // the capture phase, so closing here would destroy the callout before
8257
+ // the event reaches the handler that owns it — and that handler,
8258
+ // opening a callout on the very anchor it was just removed from, would
8259
+ // create a second one within the same click. Left open, openCallout's
8260
+ // `reopen` decides (toggle by default).
8261
+ debug(event, `click on anchor, let the anchor handler decide`);
8262
+ return;
8263
+ }
8224
8264
  requestClose(event, "click_outside");
8225
8265
  };
8226
8266
  const handleSpaceOutside = event => {
@@ -8231,6 +8271,12 @@ const openCallout = (message, {
8231
8271
  if (keyTarget === calloutElement || calloutElement.contains(keyTarget)) {
8232
8272
  return;
8233
8273
  }
8274
+ if (isInsideAnchor(keyTarget)) {
8275
+ // Space on the anchor produces a click afterwards — same reasoning as
8276
+ // handleClickOutside above.
8277
+ debug(event, `space on anchor, let the anchor handler decide`);
8278
+ return;
8279
+ }
8234
8280
  requestClose(event, "space_outside");
8235
8281
  };
8236
8282
  const registerClickOutsideListener = () => {
@@ -8403,7 +8449,9 @@ const openCallout = (message, {
8403
8449
  });
8404
8450
  allowWheelThrough(calloutElement, visualAnchorElement);
8405
8451
  anchorElement.setAttribute("data-callout", calloutId);
8406
- addTeardown(() => {
8452
+ addTeardown(({
8453
+ event
8454
+ }) => {
8407
8455
  anchorElement.removeAttribute("data-callout");
8408
8456
  });
8409
8457
  const visualElement = (() => {
@@ -12574,10 +12622,10 @@ defineInteractionDetector({
12574
12622
  if (canReorder) {
12575
12623
  element.setAttribute(REORDERABLE_ATTRIBUTE, "");
12576
12624
  }
12577
- // What @jsenv/dom puts on a drag source: no iOS callout, the touch left to
12578
- // the scroll until the press becomes a grab, and the listener that lets the
12579
- // grab take it back. Its argument is the axis the SURROUNDINGS scroll on,
12580
- // which for a list is the axis the list runs on.
12625
+ // What @jsenv/dom puts on a drag source: the axes written in the DOM for
12626
+ // whoever else answers this press (a sheet pushed down to close it, a row of
12627
+ // slides), no iOS callout, the touch left to the scroll until the press
12628
+ // becomes a grab, and the listener that lets the grab take it back.
12581
12629
  const unmarkDragSource = markDragSource(element, axes);
12582
12630
 
12583
12631
  // What a release can mean, which is not all of what was declared: "grab" is a
@@ -24141,20 +24189,26 @@ const useUIStateController = (
24141
24189
  e,
24142
24190
  "dispatching synthetic input event without data for checkbox/radio",
24143
24191
  );
24144
- el.dispatchEvent(new Event("input", { bubbles: true }));
24192
+ dispatchSyntheticInput(
24193
+ el,
24194
+ new Event("input", { bubbles: true }),
24195
+ e,
24196
+ );
24145
24197
  syntheticInputFired = true;
24146
24198
  } else {
24147
24199
  debugUIState(
24148
24200
  e,
24149
24201
  `dispatching synthetic input event with data "${newUIState}" for input`,
24150
24202
  );
24151
- el.dispatchEvent(
24203
+ dispatchSyntheticInput(
24204
+ el,
24152
24205
  new InputEvent("input", {
24153
24206
  bubbles: true,
24154
24207
  cancelable: true,
24155
24208
  inputType: "insertText",
24156
24209
  data: newUIState,
24157
24210
  }),
24211
+ e,
24158
24212
  );
24159
24213
  syntheticInputFired = true;
24160
24214
  }
@@ -24166,7 +24220,11 @@ const useUIStateController = (
24166
24220
  // A plain Event, not an InputEvent: that is what the browser
24167
24221
  // itself fires on a select, and input_effect reads the value off
24168
24222
  // the element anyway.
24169
- el.dispatchEvent(new Event("input", { bubbles: true }));
24223
+ dispatchSyntheticInput(
24224
+ el,
24225
+ new Event("input", { bubbles: true }),
24226
+ e,
24227
+ );
24170
24228
  syntheticInputFired = true;
24171
24229
  }
24172
24230
  // TODO: textarea
@@ -25269,14 +25327,16 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25269
25327
  }
25270
25328
  if (
25271
25329
  silent &&
25272
- child.uiState === undefined &&
25273
- s.realUIStateController.uiState !== undefined
25330
+ uiStateHoldsNothing(child.uiState) &&
25331
+ !uiStateHoldsNothing(s.realUIStateController.uiState)
25274
25332
  ) {
25275
25333
  // A silent sync means the child's own structure changed (children
25276
25334
  // mounted/unmounted), not that the user acted. A child that ends up
25277
25335
  // with no value there is one that currently *cannot* express one —
25278
- // a <List loading> holds no items yet which must not read as the
25279
- // user clearing the picker, nor fire its uiAction.
25336
+ // a <List loading> holds no items yet, a popup whose items are gone
25337
+ // aggregates to the empty array its stateType falls back to — which
25338
+ // must not read as the user clearing the picker, nor fire its
25339
+ // uiAction.
25280
25340
  return;
25281
25341
  }
25282
25342
  updatingRef.current = true;
@@ -25397,6 +25457,33 @@ const isInternalEvent = (e) => {
25397
25457
  return INTERNAL_EVENT_SET.has(e.type);
25398
25458
  };
25399
25459
 
25460
+ /**
25461
+ * The synthetic "input" event is how the new state reaches the outside world
25462
+ * (`uiAction` is called from the input handler it triggers). It carries what
25463
+ * caused the state change so a listener can tell a gesture apart from navi
25464
+ * talking to itself: `findEvent(e, "navi_clear_ui_state")` answers "the user
25465
+ * pressed the clear cross", the same way the facade hops are recognized.
25466
+ */
25467
+ const dispatchSyntheticInput = (el, inputEvent, causeEvent) => {
25468
+ chainEvent(inputEvent, causeEvent);
25469
+ el.dispatchEvent(inputEvent);
25470
+ };
25471
+
25472
+ // What a control says when it has nothing to say: no value at all, or the empty
25473
+ // array/object a group falls back to while it has no child to aggregate.
25474
+ const uiStateHoldsNothing = (uiState) => {
25475
+ if (uiState === undefined) {
25476
+ return true;
25477
+ }
25478
+ if (Array.isArray(uiState)) {
25479
+ return uiState.length === 0;
25480
+ }
25481
+ if (uiState !== null && typeof uiState === "object") {
25482
+ return Object.keys(uiState).length === 0;
25483
+ }
25484
+ return false;
25485
+ };
25486
+
25400
25487
  /**
25401
25488
  * Core hooks for wiring navi field components to the action/validation system.
25402
25489
  *
@@ -29086,12 +29173,13 @@ const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
29086
29173
  /**
29087
29174
  * Builds the `pointerdown` handler a popup docked to `side` answers with.
29088
29175
  *
29089
- * `grip` narrows where the gesture may start to one part of the popup, given as
29090
- * a selector matched inside it: a popup whose whole surface is a place to push
29091
- * from has nothing else to do with the press, while one made of content the
29092
- * finger operates (a bottom sheet full of controls) only offers the strip that
29093
- * is there to be held. A popup that has no such part is pushed from anywhere,
29094
- * so naming a grip never leaves it undismissable.
29176
+ * `grip` says where the gesture may start, as a selector the pressed element is
29177
+ * matched against with `closest`. A popup that names one is held THERE and
29178
+ * nowhere else: everything else it holds is content the finger came to operate,
29179
+ * and a press on it is that content's including gestures of its own, which
29180
+ * this file has no way of knowing about. A popup that names no grip is pushed
29181
+ * from its whole surface, which only suits one made of nothing else (a side
29182
+ * panel showing a page).
29095
29183
  */
29096
29184
  const createSwipeToClose = (side, { grip } = {}) => {
29097
29185
  const axis = SWIPE_AXIS_BY_SIDE[side];
@@ -29103,8 +29191,11 @@ const createSwipeToClose = (side, { grip } = {}) => {
29103
29191
  return;
29104
29192
  }
29105
29193
  if (grip) {
29106
- const gripEl = panelEl.querySelector(grip);
29107
- if (gripEl && !gripEl.contains(pointerDownEvent.target)) {
29194
+ // Read from the press outwards rather than by looking the grip up in the
29195
+ // panel: a popup may hold several (a header and whatever else was marked
29196
+ // as one), and where they sit in it is the application's business.
29197
+ const gripEl = pointerDownEvent.target.closest(grip);
29198
+ if (!gripEl || !panelEl.contains(gripEl)) {
29108
29199
  return;
29109
29200
  }
29110
29201
  }
@@ -29492,13 +29583,14 @@ const css$W = /* css */`
29492
29583
  &:has(> [data-focus-outline-delegate][data-focus-visible]) {
29493
29584
  outline-style: solid;
29494
29585
  }
29495
- /* The header of a sheet that closes by being pushed back down is a handle,
29496
- not a piece of the scroll: a finger on it moves the sheet, and letting
29497
- the browser scroll the sheet under the same gesture would show two
29498
- movements answering one drag. Direct children only the sheet's own
29499
- header, not one belonging to something it contains (the same part
29500
- swipe_to_close.js takes hold of). */
29501
- &[data-swipe-to-close] > [data-header] {
29586
+ /* What a sheet that closes by being pushed back down is held by is a
29587
+ handle, not a piece of the scroll: a finger on it moves the sheet, and
29588
+ letting the browser scroll the sheet under the same gesture would show
29589
+ two movements answering one drag. The same parts swipe_to_close.js takes
29590
+ hold of, said again here because CSS is the only place it can be said
29591
+ before the finger lands. */
29592
+ &[data-swipe-to-close] [data-header],
29593
+ &[data-swipe-to-close] [data-swipe-grip] {
29502
29594
  touch-action: none;
29503
29595
  }
29504
29596
 
@@ -29647,8 +29739,10 @@ const css$W = /* css */`
29647
29739
  * edge docking would bring it to, so docking could only take away the shape
29648
29740
  * the caller asked for. Re-resolves live as the pointer
29649
29741
  * type or the window size changes. A sheet resting on the bottom edge is also
29650
- * pushed back down to close it, held by its header (a direct child `Box` with
29651
- * the `header` prop) or from anywhere when it has none. See `swipe_to_close.js`.
29742
+ * pushed back down to close it, held by its header (a `Box` with the `header`
29743
+ * prop) and by anything else carrying `data-swipe-grip`. The rest of the sheet
29744
+ * is left to what it holds, so a board something is dragged across keeps its
29745
+ * own gestures. See `swipe_to_close.js`.
29652
29746
  * @param {string} [props.positionArea="center"] - Where to dock the dialog
29653
29747
  * within its container (the viewport for `layer="top"`, the positioned
29654
29748
  * ancestor for `layer="local"`) — Dialog is never anchored to a real
@@ -29902,12 +29996,13 @@ const DOCKED = {
29902
29996
  scrollCapture: true
29903
29997
  };
29904
29998
 
29905
- // Where a bottom sheet is held to push it back down: the strip a direct-child
29906
- // Box declares with `header` the rest of the sheet is content the finger
29907
- // operates, and a drag started there would fight whatever it landed on. A sheet
29908
- // with no header of its own is pushed from anywhere (see createSwipeToClose's
29909
- // own `grip`).
29910
- const DOCKED_SWIPE_GRIP = ":scope > [data-header]";
29999
+ // Where a bottom sheet is held to push it back down: the strip a Box declares
30000
+ // with `header`, plus anything the application marked as one more. Everything
30001
+ // else in the sheet is content the finger came to operate a board a piece is
30002
+ // dragged across, a list, a map and a press there belongs to it. A sheet with
30003
+ // no header and nothing marked is not pushed down at all; it is closed by its
30004
+ // own controls, by the backdrop and by Escape.
30005
+ const DOCKED_SWIPE_GRIP = "[data-header],[data-swipe-grip]";
29911
30006
 
29912
30007
  // The first control inside `dialogEl` that is mid-action, if any. Walks the
29913
30008
  // controls rather than reading an attribute off the dialog: a dialog carries no