@jsenv/navi 0.29.36 → 0.29.38

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.
@@ -3,7 +3,7 @@
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, coarsePointerSignal } from "./jsenv_navi_side_effects.js";
6
- 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, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, findBefore, findAfter, initFocusGroup, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
6
+ 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, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, 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
7
  export { contrastColor, findEvent, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, batch, useSignal } from "@preact/signals";
9
9
  import { createContext, isValidElement, h, Fragment, render, toChildArray, options, cloneElement } from "preact";
@@ -12550,10 +12550,11 @@ defineInteractionDetector({
12550
12550
  if (canReorder) {
12551
12551
  element.setAttribute(REORDERABLE_ATTRIBUTE, "");
12552
12552
  }
12553
- // What @jsenv/dom puts on a drag source: no iOS callout, and the touch left to
12554
- // the scroll until the press becomes a grab. Its value is the axis the
12555
- // SURROUNDINGS scroll on, which for a list is the axis the list runs on.
12556
- element.setAttribute("data-drag-source", axes === "x" ? "x" : "");
12553
+ // What @jsenv/dom puts on a drag source: no iOS callout, the touch left to
12554
+ // the scroll until the press becomes a grab, and the listener that lets the
12555
+ // grab take it back. Its argument is the axis the SURROUNDINGS scroll on,
12556
+ // which for a list is the axis the list runs on.
12557
+ const unmarkDragSource = markDragSource(element, axes);
12557
12558
 
12558
12559
  // What a release can mean, which is not all of what was declared: "grab" is a
12559
12560
  // moment, not an outcome, and the gesture must not read it as one.
@@ -12630,7 +12631,7 @@ defineInteractionDetector({
12630
12631
 
12631
12632
  return () => {
12632
12633
  element.removeAttribute(REORDERABLE_ATTRIBUTE);
12633
- element.removeAttribute("data-drag-source");
12634
+ unmarkDragSource();
12634
12635
  element.removeEventListener("pointerdown", onPointerDown);
12635
12636
  };
12636
12637
  },
@@ -21855,7 +21856,9 @@ registerNaviCommand("--navi-reset", (source, event) => {
21855
21856
  * - an open popup: it closes. The popup was there for the duration of one
21856
21857
  * decision, and the send just made it. A picker already does this when the
21857
21858
  * send targets the popup itself (executeNaviDefine); a form inside one is the
21858
- * same act, a level down;
21859
+ * same act, a level down. A popup opened from another popup answers for
21860
+ * itself only — say `data-after-send="--navi-send"` on the sending control
21861
+ * to make the decision travel up to the surface holding it;
21859
21862
  * - the document: nothing. A form on a page stays where it is.
21860
21863
  */
21861
21864
  // What follows a send that went through — and it is asked of the button that
@@ -21909,7 +21912,21 @@ registerNaviCommand("--navi-send", (source, event) => {
21909
21912
  if (target.getAttribute("aria-expanded") === "true") {
21910
21913
  return {
21911
21914
  target,
21912
- implementation: () => executeNaviDefine(source, event, target),
21915
+ implementation: () => {
21916
+ const result = executeNaviDefine(source, event, target);
21917
+ // Only what was explicitly asked for: the surface above a popup is not
21918
+ // answered by a decision taken inside it (see resolveAfterSend), so a
21919
+ // popup opened from another popup closes alone unless the send says
21920
+ // otherwise. Triggered from the target — the popup that just closed no
21921
+ // longer holds the source.
21922
+ const afterSend =
21923
+ source.getAttribute("data-after-send") ||
21924
+ target.getAttribute("data-after-send");
21925
+ if (afterSend) {
21926
+ triggerNaviCommand(target, afterSend, event, { optional: true });
21927
+ }
21928
+ return result;
21929
+ },
21913
21930
  };
21914
21931
  }
21915
21932
 
@@ -23384,7 +23401,8 @@ const useUIStateController = (
23384
23401
  controller.id = props.id; // never supposed to change, not supported for now
23385
23402
  controller.name = props.name;
23386
23403
  controller.parentUIStateController = parentUIStateController;
23387
- const { value, hasStateProp, state, stateInitial } = controlInfo;
23404
+ const { value, hasStateProp, state, stateInitial, stateFromSignal } =
23405
+ controlInfo;
23388
23406
  controller.value = value;
23389
23407
  if (hasStateProp) {
23390
23408
  controller.hasStateProp = true;
@@ -23393,9 +23411,27 @@ const useUIStateController = (
23393
23411
  controller.state = state;
23394
23412
  controller.setUIState(state, new CustomEvent("state_prop_change"));
23395
23413
  }
23396
- } else if (controller.hasStateProp) {
23397
- controller.hasStateProp = false;
23398
- controller.state = stateInitial;
23414
+ } else {
23415
+ if (controller.hasStateProp) {
23416
+ controller.hasStateProp = false;
23417
+ controller.state = stateInitial;
23418
+ }
23419
+ if (controlInfo.signal) {
23420
+ // The other half of a bound signal: the control follows it when
23421
+ // something else writes it. Compared against `state` (what the signal
23422
+ // last said), never against the ui state — otherwise typing into an
23423
+ // uncontrolled control would be undone on the next render. A write
23424
+ // coming from the control itself lands here with the value already in
23425
+ // place, and setUIState treats an unchanged state as a no-op.
23426
+ const currentState = controller.state;
23427
+ if (!compareTwoJsValues(stateFromSignal, currentState)) {
23428
+ controller.state = stateFromSignal;
23429
+ controller.setUIState(
23430
+ stateFromSignal,
23431
+ new CustomEvent("state_prop_change"),
23432
+ );
23433
+ }
23434
+ }
23399
23435
  }
23400
23436
  return {
23401
23437
  ref: props.ref,
@@ -23675,10 +23711,9 @@ const useUIGroupStateController = (
23675
23711
  const hasValueProp = Object.hasOwn(props, "value");
23676
23712
  const hasOwnDefaultValueProp = Object.hasOwn(props, "defaultValue");
23677
23713
  // A bound signal seeds the group the way `defaultValue` does — uncontrolled,
23678
- // with the signal's current value as what it starts on. Write-back is already
23679
- // handled (see applyState's own boundSignal), so this is the half that makes
23680
- // `signal` two-way on a group: the children are placed from it when they
23681
- // register, exactly as they would be from a defaultValue.
23714
+ // with the signal's current value as what it starts on. Write-back is handled
23715
+ // by applyState's own boundSignal; the read half is below: children are
23716
+ // placed from it when they register, and again whenever it moves.
23682
23717
  const boundSignal = hasValueProp ? undefined : props.signal;
23683
23718
  const hasDefaultValueProp = hasOwnDefaultValueProp || Boolean(boundSignal);
23684
23719
  const { id, name, value, uiAction } = props;
@@ -24096,6 +24131,7 @@ const useUIGroupStateController = (
24096
24131
  const { controller } = s;
24097
24132
  const prevValue = controller.value;
24098
24133
  const prevHasValueProp = controller.hasValueProp;
24134
+ const prevDefaultValue = controller.defaultValue;
24099
24135
  controller.props = props;
24100
24136
  controller.ref = ref;
24101
24137
  controller.id = id;
@@ -24124,6 +24160,31 @@ const useUIGroupStateController = (
24124
24160
  }
24125
24161
  controller.syncInternalState(value);
24126
24162
  }
24163
+ if (
24164
+ boundSignal &&
24165
+ !hasValueProp &&
24166
+ !compareTwoJsValues(defaultValue, prevDefaultValue)
24167
+ ) {
24168
+ // The signal moved from the outside: the children are placed from it
24169
+ // again, exactly as they were when they registered. Without this a
24170
+ // group would answer a write to its own signal by silently writing its
24171
+ // former value back over it on the next child interaction.
24172
+ const propagateDownEvent = new CustomEvent(
24173
+ "propagate_down_set_ui_state",
24174
+ { detail: {} },
24175
+ );
24176
+ for (const childUIStateController of childUIStateControllerArray) {
24177
+ if (!shouldPropagateStateToChild(childUIStateController)) continue;
24178
+ if (childUIStateController.hasStateProp) continue;
24179
+ const childNewState = controller.distributeChildUIState(
24180
+ defaultValue,
24181
+ childUIStateController,
24182
+ );
24183
+ if (childNewState === CANNOT_DERIVE) continue;
24184
+ childUIStateController.setUIState(childNewState, propagateDownEvent);
24185
+ }
24186
+ controller.syncInternalState(defaultValue);
24187
+ }
24127
24188
 
24128
24189
  return {
24129
24190
  ref,
@@ -25248,6 +25309,13 @@ const createControlInfo = (props, {
25248
25309
  const signal = Object.hasOwn(props, "signal") ? props.signal : undefined;
25249
25310
  // For a checkbox/radio the signal holds the boolean checked state, not the value.
25250
25311
  let signalHoldsChecked = false;
25312
+ // What the signal says the control shows, re-read on every render. A signal
25313
+ // carrying a default of its own seeds `defaultValue` (resolveInputProps), so
25314
+ // such a control is uncontrolled — but the binding still has to work in both
25315
+ // directions: whoever writes the signal from the outside expects the control
25316
+ // to move. This is what the controller re-syncs on (see useUIStateController),
25317
+ // and an emptied signal puts the control back on its suggestion.
25318
+ let stateFromSignal;
25251
25319
  if (controlType === "input") {
25252
25320
  if (typeProp === "checkbox" || typeProp === "radio") {
25253
25321
  statePropName = "checked";
@@ -25259,10 +25327,13 @@ const createControlInfo = (props, {
25259
25327
  stateInitial = props.checked ? value : undefined;
25260
25328
  } else if (props.defaultChecked) {
25261
25329
  // resolveInputProps may seed defaultChecked from a bound signal's
25262
- // default: the control stays uncontrolled and only write-syncs the
25263
- // signal (onUIAction).
25330
+ // default: the control stays uncontrolled and follows the signal
25331
+ // through stateFromSignal.
25264
25332
  hasStateProp = false;
25265
25333
  stateInitial = value;
25334
+ if (signal) {
25335
+ stateFromSignal = signal.value ? value : undefined;
25336
+ }
25266
25337
  } else if (signal) {
25267
25338
  // A bound signal with no resolved default: its live value seeds state.
25268
25339
  hasStateProp = true;
@@ -25289,6 +25360,9 @@ const createControlInfo = (props, {
25289
25360
  // url or set by whoever owns it. Taking the default here would show a
25290
25361
  // suggestion in place of the value on every reload.
25291
25362
  stateInitial = signal && signal.value !== undefined ? signal.value : props.defaultValue;
25363
+ if (signal) {
25364
+ stateFromSignal = stateInitial;
25365
+ }
25292
25366
  } else if (signal) {
25293
25367
  // A plain bound signal with no default (e.g. Wheel): its live value
25294
25368
  // seeds and controls the state.
@@ -25322,6 +25396,9 @@ const createControlInfo = (props, {
25322
25396
  // Same precedence as an input above: the signal's value is the answer,
25323
25397
  // defaultValue only the suggestion to start from.
25324
25398
  stateInitial = signal && signal.value !== undefined ? signal.value : props.defaultValue;
25399
+ if (signal) {
25400
+ stateFromSignal = stateInitial;
25401
+ }
25325
25402
  } else if (signal) {
25326
25403
  hasStateProp = true;
25327
25404
  stateInitial = signal.value;
@@ -25345,6 +25422,7 @@ const createControlInfo = (props, {
25345
25422
  value,
25346
25423
  signal,
25347
25424
  signalHoldsChecked,
25425
+ stateFromSignal,
25348
25426
  readOnlySupported,
25349
25427
  disabledSupported
25350
25428
  };
@@ -26824,7 +26902,19 @@ const findFocusTarget = (containerEl) => {
26824
26902
  // Neither is dropped, both are simply tried later — step 3 below for the
26825
26903
  // first, and for the second the restore transferFocus does before ever
26826
26904
  // calling here.
26827
- const skip = (element) => isRestorableAutofocus(element);
26905
+ //
26906
+ // Skipped for good, unlike the two above: an element hidden from assistive
26907
+ // technology is not a place the focus can land at all. Something aria-hidden
26908
+ // and out of the tab order is a value holder standing behind what one
26909
+ // actually uses — a spin's headless picker behind its slides, say — and
26910
+ // landing there puts a ring on it, raises a phone's keyboard over the panel
26911
+ // that just opened, and has the browser complain about a focused aria-hidden
26912
+ // element. What one came to use is further down the same container.
26913
+ const isHiddenFromAssistiveTech = (element) =>
26914
+ Boolean(element.closest?.(`[aria-hidden="true"]`));
26915
+
26916
+ const skip = (element) =>
26917
+ isRestorableAutofocus(element) || isHiddenFromAssistiveTech(element);
26828
26918
 
26829
26919
  // Every mark, not just the first: a mark is only worth stopping at if it
26830
26920
  // leads somewhere focusable. One inside a screen waiting its turn (an inert
@@ -36881,12 +36971,19 @@ const css$R = /* css */`
36881
36971
  travelling. */
36882
36972
  .navi_route_travel {
36883
36973
  position: relative;
36884
- /* The gesture takes the axis the pages travel on and leaves the other one
36885
- to the page, so a list still scrolls under the same finger. */
36886
- touch-action: pan-y;
36887
- }
36888
- .navi_route_travel[data-axis="y"] {
36889
- touch-action: pan-x;
36974
+
36975
+ /* What a touch may do here only where a touch travels at all
36976
+ (data-travel-by-drag, set from travelByDrag): the axis the pages travel
36977
+ on is taken, the other one is left to the page, so a list still scrolls
36978
+ under the same finger. A box that takes no gesture takes no axis either:
36979
+ it is a plain box around the page, and everything in it scrolls as it
36980
+ would anywhere else. Same reading as SlideContainer's own. */
36981
+ &[data-travel-by-drag="x"] {
36982
+ touch-action: pan-y;
36983
+ }
36984
+ &[data-travel-by-drag="y"] {
36985
+ touch-action: pan-x;
36986
+ }
36890
36987
  }
36891
36988
 
36892
36989
  /* Only while a travel of OURS is playing: everything below changes how the
@@ -37107,6 +37204,11 @@ const css$R = /* css */`
37107
37204
  * page under six entries. A tab pressed is the other case and pushes, which
37108
37205
  * is what its <Link> already does.
37109
37206
  *
37207
+ * Every other prop lands on the box itself (`id`, `data-testid`, `className`,
37208
+ * …), which is how one of these is named: a page can hold several — a row of
37209
+ * sections inside the box the whole application travels in — and the class they
37210
+ * share plus their axis are not enough to tell them apart from the outside.
37211
+ *
37110
37212
  * The pages are cut at the edge of this box while they travel, which is written
37111
37213
  * on the transition's own pseudo-elements — no overflow of the document reaches
37112
37214
  * pictures drawn in the top layer. It needs nothing of the browser beyond view
@@ -46723,12 +46825,13 @@ const NAVI_TYPE_DEFAULTS = {
46723
46825
  */
46724
46826
  /**
46725
46827
  * A bound signal that carries a default of its own says the same thing on every
46726
- * control: the control starts there and is otherwise uncontrolled (it
46727
- * write-syncs the signal), which is what makes a form read the value shown as a
46728
- * SUGGESTION rather than as something it already holds. Written once and used
46729
- * by everything that takes a `signal`a wheel that skipped this was the same
46730
- * signal meaning two different things depending on which control it was handed
46731
- * to (see wheel.jsx).
46828
+ * control: the control starts there and stays uncontrolled, which is what makes
46829
+ * a form read the value shown as a SUGGESTION rather than as something it
46830
+ * already holds. Uncontrolled here is about what the control HOLDS, not about
46831
+ * whether it follows the signal — the binding stays two-way either way (see
46832
+ * stateFromSignal in control_hooks.jsx). Written once and used by everything
46833
+ * that takes a `signal`, so one signal cannot mean two different things
46834
+ * depending on which control it was handed to.
46732
46835
  */
46733
46836
  const seedDefaultValueFromSignal = (props) => {
46734
46837
  const signalOptions = props.signal?.options;
@@ -46750,11 +46853,11 @@ const seedDefaultValueFromSignal = (props) => {
46750
46853
 
46751
46854
  const resolveInputProps = (props) => {
46752
46855
  // `signal` carries a bound state signal. It is left on `props` on purpose:
46753
- // `createControlInfo` (control_hooks.jsx) reads it to seed the state and
46754
- // `onUIAction` (ui_state_controller.js) writes user interactions back into
46755
- // it. Here we only derive input defaults (type/min/max, defaultValue/
46756
- // defaultChecked) from the signal's `options`, so the control ends up
46757
- // uncontrolled-with-default while still write-syncing the signal.
46856
+ // `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
46857
+ // follow it, and `onUIAction` (ui_state_controller.js) writes user
46858
+ // interactions back into it. Here we only derive input defaults (type/min/max,
46859
+ // defaultValue/defaultChecked) from the signal's `options`, so the control
46860
+ // ends up uncontrolled-with-default while still bound to the signal.
46758
46861
  const signal = props.signal;
46759
46862
  if (signal) {
46760
46863
  const signalOptions = signal.options;
@@ -51371,6 +51474,12 @@ installImportMetaCssBuild(import.meta);const css$y = /* css */`
51371
51474
  --dialog-outline-width: var(--picker-outline-width);
51372
51475
  --dialog-outline-color: var(--picker-outline-color);
51373
51476
 
51477
+ /* No fallback on purpose (same as --popover-max-height above): unset
51478
+ picker props leave these declarations invalid at computed-value
51479
+ time, so the dialog keeps its own ceilings. */
51480
+ --dialog-max-width: var(--picker-dialog-max-width);
51481
+ --dialog-max-height: var(--picker-dialog-max-height);
51482
+
51374
51483
  /* Dialog itself already sizes min-width off --anchor-width — only
51375
51484
  the cursor reset below is picker-specific here. */
51376
51485
  cursor: default; /* Reset pointer cursor within the select */
@@ -51489,7 +51598,13 @@ const PickerCustom = props => {
51489
51598
  ref,
51490
51599
  mode: modeProp,
51491
51600
  open,
51492
- defaultOpen
51601
+ defaultOpen,
51602
+ // What Escape means for this picker. "cancel" (the default) puts back the
51603
+ // value the picker had at open and, for a dialog, goes back in history —
51604
+ // so everything written to the url while it was open goes back too.
51605
+ // "close" makes Escape say the same thing as clicking outside: keep what
51606
+ // was chosen, close the popup.
51607
+ escapeEffect = "cancel"
51493
51608
  } = props;
51494
51609
  // Resolve the id the same way useControlProps does (own id > Field's id > generated id)
51495
51610
  // before computing popupId below, so two Pickers without an explicit id never collide.
@@ -51521,6 +51636,7 @@ const PickerCustom = props => {
51521
51636
  // otherwise leak through PickerContentInsidePopup's own ...rest).
51522
51637
  delete pickerProps.open;
51523
51638
  delete pickerProps.defaultOpen;
51639
+ delete pickerProps.escapeEffect;
51524
51640
  const popupProps = {};
51525
51641
  Object.assign(pickerProps, {
51526
51642
  popupProps,
@@ -51789,11 +51905,12 @@ const PickerCustom = props => {
51789
51905
  if (!openController.opened) {
51790
51906
  return null;
51791
51907
  }
51908
+ const isCancel = escapeEffect === "cancel";
51792
51909
  return {
51793
- name: "escape_to_cancel",
51910
+ name: isCancel ? "escape_to_cancel" : "escape_to_close",
51794
51911
  allowed: () => {
51795
51912
  requestClose(e, {
51796
- isCancel: true
51913
+ isCancel
51797
51914
  });
51798
51915
  e.preventDefault(); // prevent browser from closing the dialog (if any)
51799
51916
  }
@@ -51952,7 +52069,7 @@ const PickerContentInsidePopup = props => {
51952
52069
  animation: animation,
51953
52070
  positionArea: isPopover ? positionArea ?? (popoverMode === "nearby" ? "bottom-start" : "inset(top-left)") : positionArea,
51954
52071
  marginWithAnchor: isPopover ? popoverSpacing : undefined,
51955
- marginWithContainer: marginWithContainer === undefined && isPopover ? popoverSpacing : marginWithContainer ? 10 : marginWithContainer,
52072
+ marginWithContainer: marginWithContainer === undefined && isPopover ? popoverSpacing : marginWithContainer,
51956
52073
  scrollCapture: scrollCapture,
51957
52074
  pointerInteractionOutsideEffect: pointerLock ? "capture" : pointerInteractionOutsideEffect,
51958
52075
  focusCapture: isPopover ? focusCapture : undefined,
@@ -52845,6 +52962,11 @@ installImportMetaCssBuild(import.meta);const css$w = /* css */`
52845
52962
  }
52846
52963
  `;
52847
52964
  const SelectableListMultipleContext = createContext(false);
52965
+ // A row of a selectable list is selectable — the list is what decides, and a
52966
+ // row says nothing unless it wants out (`selectable={false}` on a row that is
52967
+ // only there to be read). Also set to false by a non-selectable list, so a list
52968
+ // nested inside a selectable row does not inherit the outer list's answer.
52969
+ const ListSelectableContext = createContext(false);
52848
52970
  // Interactive variant: manages hover/keyboard/selection state and handles the
52849
52971
  // navi event protocol. When an action is provided it binds the action to ui state
52850
52972
  // and fires it on select. When only uiAction is provided it calls it directly.
@@ -52855,8 +52977,11 @@ const ListSelectableResolver = props => {
52855
52977
  ...props
52856
52978
  });
52857
52979
  }
52858
- return jsx(Next, {
52859
- ...props
52980
+ return jsx(ListSelectableContext.Provider, {
52981
+ value: false,
52982
+ children: jsx(Next, {
52983
+ ...props
52984
+ })
52860
52985
  });
52861
52986
  };
52862
52987
  const ListSelectable = props => {
@@ -53146,17 +53271,27 @@ const ListSelectable = props => {
53146
53271
  children: props.children
53147
53272
  })
53148
53273
  });
53149
- return jsx(SelectableListMultipleContext.Provider, {
53150
- value: multiple,
53151
- children: listVnode
53274
+ return jsx(ListSelectableContext.Provider, {
53275
+ value: true,
53276
+ children: jsx(SelectableListMultipleContext.Provider, {
53277
+ value: multiple,
53278
+ children: listVnode
53279
+ })
53152
53280
  });
53153
53281
  };
53154
53282
  const SelectableRealInputContext = createContext(null);
53155
53283
  const ListItemSelectableResolver = props => {
53156
53284
  const Next = useNextResolver();
53157
- if (props.selectable) {
53285
+ const listSelectable = useContext(ListSelectableContext);
53286
+ // A header/footer row is a title, not a choice — it stays out even in a
53287
+ // selectable list. (A skeleton row never reaches here: its own resolver runs
53288
+ // before this one.)
53289
+ const isHeaderOrFooter = Boolean(props.header || props.footer);
53290
+ const selectable = props.selectable ?? (isHeaderOrFooter || props.role === "presentation" ? false : listSelectable);
53291
+ if (selectable) {
53158
53292
  return jsx(ListItemSelectable, {
53159
- ...props
53293
+ ...props,
53294
+ selectable: true
53160
53295
  });
53161
53296
  }
53162
53297
  return jsx(Next, {
@@ -53182,6 +53317,10 @@ const ListItemSelectable = props => {
53182
53317
  selected,
53183
53318
  pointed,
53184
53319
  selectableArea = "all",
53320
+ // The row's own click handler, kept out of the control props below: those
53321
+ // describe the hidden input that carries the selection, and a caller
53322
+ // writing onClick on a <List.Item> is talking about the row they see.
53323
+ onClick,
53185
53324
  ...rest
53186
53325
  } = props;
53187
53326
  const multiple = useContext(SelectableListMultipleContext);
@@ -53236,6 +53375,7 @@ const ListItemSelectable = props => {
53236
53375
  ...basePseudoState
53237
53376
  },
53238
53377
  ref: props.ref,
53378
+ onClick: onClick,
53239
53379
  selectable: undefined,
53240
53380
  "navi-selectable-area-all": selectableArea === "all" ? "" : undefined,
53241
53381
  children: [jsx(SelectableRealInput, {
@@ -53514,6 +53654,26 @@ const css$v = /* css */`
53514
53654
  --list-border-width-default: 0px;
53515
53655
  }
53516
53656
 
53657
+ /* Same reasoning, for the corners: a dialog squares off whatever corner
53658
+ lands on its container's own (see the data-flush-* rules in dialog.jsx —
53659
+ a bottom sheet from dockedOnTouch squares its two bottom ones). A list
53660
+ drawn right against that corner has to square the same one, otherwise its
53661
+ own radius carves a notch out of the popup's square corner. Direct child
53662
+ only: any deeper and the list is presumably inset from the popup's edge,
53663
+ where its own radius is the right one. */
53664
+ .navi_dialog[data-flush-top][data-flush-left] > .navi_list_container {
53665
+ border-top-left-radius: 0;
53666
+ }
53667
+ .navi_dialog[data-flush-top][data-flush-right] > .navi_list_container {
53668
+ border-top-right-radius: 0;
53669
+ }
53670
+ .navi_dialog[data-flush-bottom][data-flush-right] > .navi_list_container {
53671
+ border-bottom-right-radius: 0;
53672
+ }
53673
+ .navi_dialog[data-flush-bottom][data-flush-left] > .navi_list_container {
53674
+ border-bottom-left-radius: 0;
53675
+ }
53676
+
53517
53677
  .navi_list_container {
53518
53678
  --x-list-border-radius: var(--list-border-radius);
53519
53679
  --x-list-border-width: var(
@@ -56359,7 +56519,8 @@ const LIST_ITEM_STYLE_CSS_VARS = {
56359
56519
  * @param {boolean} [props.selectable]
56360
56520
  * The item participates in selection (radio or checkbox depending on whether
56361
56521
  * the parent List has `multiple`). Requires `value` and typically a
56362
- * <SelectableInput /> child.
56522
+ * <SelectableInput /> child. Inherited from `<List selectable>` — pass
56523
+ * `false` for a row that is only there to be read, and nothing otherwise.
56363
56524
  * @param {any} [props.value]
56364
56525
  * The JS value emitted by the list's action/uiAction when this item is
56365
56526
  * selected. Can be any type (string, number, object…).
@@ -57661,7 +57822,7 @@ const PickerTypeResolver = props => {
57661
57822
  const PickerText = props => {
57662
57823
  const Next = useNextResolver();
57663
57824
  return jsx(Next, {
57664
- icon: jsx(PencilSvg, {}),
57825
+ rightSlotIcon: jsx(PencilSvg, {}),
57665
57826
  ...props
57666
57827
  });
57667
57828
  };
@@ -57740,7 +57901,7 @@ const PickerColor = props => {
57740
57901
  const Next = useNextResolver();
57741
57902
  return jsx(Next, {
57742
57903
  ui: jsx(PickerColorUI, {}),
57743
- icon: jsx(ColorSvg, {}),
57904
+ rightSlotIcon: jsx(ColorSvg, {}),
57744
57905
  type: "color",
57745
57906
  ...props
57746
57907
  });
@@ -57764,7 +57925,7 @@ const PickerDate = props => {
57764
57925
  const Next = useNextResolver();
57765
57926
  return jsx(Next, {
57766
57927
  ui: jsx(PickerDateUI, {}),
57767
- icon: jsx(CalendarSvg, {}),
57928
+ rightSlotIcon: jsx(CalendarSvg, {}),
57768
57929
  ...props,
57769
57930
  type: "date"
57770
57931
  });
@@ -57796,7 +57957,7 @@ const PickerMonth = props => {
57796
57957
  const Next = useNextResolver();
57797
57958
  return jsx(Next, {
57798
57959
  ui: jsx(PickerMonthUI, {}),
57799
- icon: jsx(CalendarSvg, {}),
57960
+ rightSlotIcon: jsx(CalendarSvg, {}),
57800
57961
  ...props,
57801
57962
  type: "month"
57802
57963
  });
@@ -57827,7 +57988,7 @@ const PickerWeek = props => {
57827
57988
  const Next = useNextResolver();
57828
57989
  return jsx(Next, {
57829
57990
  ui: jsx(PickerWeekUI, {}),
57830
- icon: jsx(CalendarSvg, {}),
57991
+ rightSlotIcon: jsx(CalendarSvg, {}),
57831
57992
  ...props,
57832
57993
  type: "week"
57833
57994
  });
@@ -57858,7 +58019,7 @@ const PickerTime = props => {
57858
58019
  const Next = useNextResolver();
57859
58020
  return jsx(Next, {
57860
58021
  ui: jsx(PickerTimeUI, {}),
57861
- icon: jsx(ClockSvg, {}),
58022
+ rightSlotIcon: jsx(ClockSvg, {}),
57862
58023
  ...props,
57863
58024
  type: "time"
57864
58025
  });
@@ -57888,7 +58049,7 @@ const PickerDuration = props => {
57888
58049
  const Next = useNextResolver();
57889
58050
  return jsx(Next, {
57890
58051
  ui: jsx(PickerDurationUI, {}),
57891
- icon: jsx(DurationSvg, {}),
58052
+ rightSlotIcon: jsx(DurationSvg, {}),
57892
58053
  ...props,
57893
58054
  type: "text",
57894
58055
  "navi-input-type": "duration"
@@ -57919,7 +58080,7 @@ const PickerDatetime = props => {
57919
58080
  const Next = useNextResolver();
57920
58081
  return jsx(Next, {
57921
58082
  ui: jsx(PickerDatetimeUI, {}),
57922
- icon: jsx(CalendarSvg, {}),
58083
+ rightSlotIcon: jsx(CalendarSvg, {}),
57923
58084
  ...props,
57924
58085
  type: "datetime-local"
57925
58086
  });
@@ -57948,7 +58109,7 @@ const PickerFile = props => {
57948
58109
  const Next = useNextResolver();
57949
58110
  return jsx(Next, {
57950
58111
  ui: jsx(PickerFileUI, {}),
57951
- icon: jsx(FileSvg, {}),
58112
+ rightSlotIcon: jsx(FileSvg, {}),
57952
58113
  type: "file",
57953
58114
  ...props
57954
58115
  });
@@ -58288,6 +58449,28 @@ installImportMetaCssBuild(import.meta);const css$r = /* css */`
58288
58449
  --x-picker-background-color: transparent;
58289
58450
  --x-picker-icon-color: currentColor;
58290
58451
  }
58452
+ /* discrete: no box at rest, a background on hover — the same word Button
58453
+ uses, and the same drawing. What is read is the value, not the field
58454
+ around it; the chevron in the right slot is what still says it opens. */
58455
+ &[data-variant="discrete"] {
58456
+ --picker-border-width: 0px; /* must carry a unit (px) — used in calc() to offset the custom input overlay */
58457
+ --x-picker-border-color: transparent;
58458
+ --x-picker-background-color: transparent;
58459
+
58460
+ &[data-hover] {
58461
+ --x-picker-border-color: transparent;
58462
+ --x-picker-background-color: color-mix(
58463
+ in srgb,
58464
+ currentColor 8%,
58465
+ transparent
58466
+ );
58467
+ }
58468
+ &[data-readonly],
58469
+ &[data-disabled] {
58470
+ --x-picker-border-color: transparent;
58471
+ --x-picker-background-color: transparent;
58472
+ }
58473
+ }
58291
58474
  &[data-variant="headless"] {
58292
58475
  --x-picker-padding-top: 0;
58293
58476
  --x-picker-padding-right: 0;
@@ -58318,8 +58501,8 @@ const PickerButton = props => {
58318
58501
  const {
58319
58502
  ref,
58320
58503
  variant,
58321
- icon,
58322
- iconSize = "inherit",
58504
+ rightSlotIcon,
58505
+ rightSlotIconSize = "inherit",
58323
58506
  placeholder,
58324
58507
  ui,
58325
58508
  maxLines = 1,
@@ -58364,8 +58547,8 @@ const PickerButton = props => {
58364
58547
  basePseudoState: basePseudoState,
58365
58548
  styleCSSVars: PickerStyleCSSVars,
58366
58549
  variant: undefined,
58367
- icon: undefined,
58368
- iconSize: undefined,
58550
+ rightSlotIcon: undefined,
58551
+ rightSlotIconSize: undefined,
58369
58552
  ui: undefined,
58370
58553
  maxLines: undefined,
58371
58554
  popupWidthFitContent: undefined,
@@ -58502,7 +58685,7 @@ const PickerButton = props => {
58502
58685
  flex: true,
58503
58686
  align: "center",
58504
58687
  children: jsx(Icon, {
58505
- size: iconSize,
58688
+ size: rightSlotIconSize,
58506
58689
  lineOverflow: "allow",
58507
58690
  children: jsx(CloseSvg, {})
58508
58691
  })
@@ -58511,9 +58694,9 @@ const PickerButton = props => {
58511
58694
  // character — a caller asking for a bigger one wants it bigger,
58512
58695
  // not capped at the height of the line it sits on
58513
58696
  jsx(Icon, {
58514
- size: iconSize,
58697
+ size: rightSlotIconSize,
58515
58698
  lineOverflow: "allow",
58516
- children: icon === undefined ? jsx(ChevronDownSvg$1, {}) : icon
58699
+ children: rightSlotIcon === undefined ? jsx(ChevronDownSvg$1, {}) : rightSlotIcon
58517
58700
  })
58518
58701
  })]
58519
58702
  }), jsx(ControlFacadeChildrenWrapper, {
@@ -58604,6 +58787,8 @@ const PickerStyleCSSVars = {
58604
58787
  "borderWidth": "--picker-border-width",
58605
58788
  "borderRadius": "--picker-border-radius",
58606
58789
  "popoverMaxHeight": "--picker-popover-max-height",
58790
+ "dialogMaxWidth": "--picker-dialog-max-width",
58791
+ "dialogMaxHeight": "--picker-dialog-max-height",
58607
58792
  "popupBackgroundColor": "--picker-popup-background-color",
58608
58793
  "popupBorderRadius": "--picker-popup-border-radius",
58609
58794
  "dialogBorderWidth": "--picker-dialog-border-width",
@@ -58683,11 +58868,14 @@ const PickerFirstResolver = props => {
58683
58868
  * popoverMode?: "nearby" | "overlay",
58684
58869
  * positionArea?: string,
58685
58870
  * popupWidthFitContent?: boolean,
58686
- * variant?: "icon" | "headless",
58687
- * icon?: import("ignore:preact").ComponentChildren,
58871
+ * variant?: "icon" | "headless" | "discrete",
58872
+ * rightSlotIcon?: import("ignore:preact").ComponentChildren,
58873
+ * rightSlotIconSize?: number | string,
58688
58874
  * maxLines?: number,
58689
58875
  * slotSpacing?: number | string,
58690
58876
  * popoverMaxHeight?: number | string,
58877
+ * dialogMaxWidth?: number | string,
58878
+ * dialogMaxHeight?: number | string,
58691
58879
  * popupBackgroundColor?: string,
58692
58880
  * popupBorderRadius?: number | string,
58693
58881
  * clearable?: boolean,
@@ -58695,6 +58883,9 @@ const PickerFirstResolver = props => {
58695
58883
  * dialogExpand?: boolean,
58696
58884
  * dialogExpandX?: boolean,
58697
58885
  * dialogExpandY?: boolean,
58886
+ * marginWithContainer?: number | string,
58887
+ * escapeEffect?: "cancel" | "close",
58888
+ * pointerInteractionOutsideEffect?: "close" | "cancel" | "capture",
58698
58889
  * ref?: import("ignore:preact").RefObject<HTMLElement>,
58699
58890
  * [key: string]: any,
58700
58891
  * }>}
@@ -58714,6 +58905,13 @@ const PickerFirstResolver = props => {
58714
58905
  * @param {boolean} [popupWidthFitContent] By default the popup is at least as
58715
58906
  * wide as the trigger. Set this to let the content size it instead, so a
58716
58907
  * popup narrower than the trigger stays narrow.
58908
+ * @param {import("ignore:preact").ComponentChildren} [rightSlotIcon] What the right
58909
+ * slot draws in place of the chevron. It is the whole slot, not an addition
58910
+ * to it: the picker then no longer says on its own that it opens, so pass
58911
+ * something that does.
58912
+ * @param {number|string} [rightSlotIconSize="inherit"] How big what sits in the
58913
+ * right slot is drawn — the chevron, a `rightSlotIcon`, or the clear button's
58914
+ * cross. "inherit" takes the picker's own font size.
58717
58915
  * @param {number|string} [slotSpacing] Gap kept between what sits in the right
58718
58916
  * slot (the chevron, or the clear button) and the picker's own edge — same
58719
58917
  * prop name Input uses for its own slots. Accepts a spacing token ("s",
@@ -58721,6 +58919,25 @@ const PickerFirstResolver = props => {
58721
58919
  * horizontal padding.
58722
58920
  * @param {number|string} [popoverMaxHeight] Soft cap on the popover's height
58723
58921
  * (default 300px). The popover shrinks below it when space is tight.
58922
+ * @param {number|string} [dialogMaxWidth] Ceiling on the dialog's width, the
58923
+ * one `dialogExpand`/`dialogExpandX` grows up to — what makes an expanded
58924
+ * dialog a large sheet rather than a full screen. Capped in turn by the
58925
+ * container minus `marginWithContainer`.
58926
+ * @param {number|string} [dialogMaxHeight] Same, on the height.
58927
+ * @param {"cancel"|"close"} [escapeEffect="cancel"] What Escape does to an open
58928
+ * picker. "cancel" puts back the value the picker had at open, and a dialog
58929
+ * picker also goes back in history — so anything written to the url while it
58930
+ * was open (a route `stateSignal`, a search param) goes back with it. "close"
58931
+ * makes Escape say what clicking outside says: keep what was chosen, close.
58932
+ * @param {"close"|"cancel"|"capture"} [pointerInteractionOutsideEffect="close"]
58933
+ * What a click outside the popup does: close and keep ("close"), close and
58934
+ * put back the value at open ("cancel"), or nothing at all ("capture").
58935
+ * @param {number|string} [marginWithContainer] Minimum gap kept between the
58936
+ * popup and the edges of what contains it (the viewport, or the picker's own
58937
+ * positioned ancestor for `popupLayer="local"`). Caps the popup's size as
58938
+ * well as its placement, so what an expanded dialog leaves visible around
58939
+ * itself is set here. Defaults to `popoverSpacing` in popover mode, and to
58940
+ * Dialog's own 3vvw in dialog mode.
58724
58941
  */
58725
58942
  const Picker = createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
58726
58943
  Picker.UI = PickerDefaultUI;
@@ -60540,12 +60757,26 @@ const usePlaceholderHeight = (ref, placeholder) => {
60540
60757
  textareaEl.style.setProperty("--x-textarea-placeholder-height", `${contentHeight}px`);
60541
60758
  };
60542
60759
  measure();
60760
+ // Measuring writes the variable that sets this element's own height, and
60761
+ // mutating layout from inside a resize callback is what makes the browser
60762
+ // report "ResizeObserver loop completed with undelivered notifications".
60763
+ // So the write waits for the frame that resize produced.
60764
+ let measureFrame = null;
60765
+ const requestMeasure = () => {
60766
+ if (measureFrame !== null) {
60767
+ return;
60768
+ }
60769
+ measureFrame = requestAnimationFrame(() => {
60770
+ measureFrame = null;
60771
+ measure();
60772
+ });
60773
+ };
60543
60774
  // The placeholder wraps against the available width, so a new width is a
60544
60775
  // new number of lines. Height changes are ignored: this measure is what
60545
60776
  // causes them, and reacting to them would be reacting to ourselves.
60546
60777
  const resizeObserver = new ResizeObserver(() => {
60547
60778
  if (textareaEl.clientWidth !== widthMeasured) {
60548
- measure();
60779
+ requestMeasure();
60549
60780
  }
60550
60781
  });
60551
60782
  resizeObserver.observe(textareaEl);
@@ -60558,6 +60789,9 @@ const usePlaceholderHeight = (ref, placeholder) => {
60558
60789
  };
60559
60790
  textareaEl.addEventListener("input", onInput);
60560
60791
  return () => {
60792
+ if (measureFrame !== null) {
60793
+ cancelAnimationFrame(measureFrame);
60794
+ }
60561
60795
  resizeObserver.disconnect();
60562
60796
  textareaEl.removeEventListener("input", onInput);
60563
60797
  };
@@ -69586,6 +69820,150 @@ const ViewportLayout = props => {
69586
69820
  });
69587
69821
  };
69588
69822
 
69823
+ /**
69824
+ * Pushing a side panel back the way it came in closes it: the panel follows
69825
+ * the finger while it travels, and letting go either finishes the departure or
69826
+ * brings it back to rest — whichever the travel was already heading to.
69827
+ *
69828
+ * Reading the pointer is not done here: when a press becomes a gesture, which
69829
+ * axis it leans on, how fast it was going when it was let go, who else has a
69830
+ * claim on it (a field, a scroller with room left, a box inside the panel that
69831
+ * travels the same way) is one gesture system for the whole codebase
69832
+ * (@jsenv/dom's startDragToTravel). A panel closing is a travel of exactly one
69833
+ * box, towards the edge it is docked to and nowhere else — which is all this
69834
+ * file has to say.
69835
+ *
69836
+ * The release travel is driven here (Web Animations) rather than handed to the
69837
+ * exit transition the `animation` prop may install: its pace is what is left to
69838
+ * cover, which a CSS transition written for a full-size travel cannot know, and
69839
+ * two movements on the same property would contradict each other. So CSS
69840
+ * transitions are suppressed on the panel for the whole gesture, release
69841
+ * included, and restored once the panel has either left or come back.
69842
+ */
69843
+
69844
+
69845
+ // What a full-size release travel takes; a shorter one takes proportionally
69846
+ // less, so the panel keeps the same speed whenever the finger let go.
69847
+ const TRAVEL_DURATION = 220;
69848
+
69849
+ // Which way a panel docked to each side travels — read by the panel itself to
69850
+ // say so in the DOM, which is how a box travelling INSIDE it (a row of slides,
69851
+ // a route travel) knows that axis is already walked.
69852
+ const SWIPE_AXIS_BY_SIDE = {
69853
+ left: "x",
69854
+ right: "x",
69855
+ top: "y",
69856
+ bottom: "y",
69857
+ };
69858
+ // Which way the finger pushes to send the panel back where it came from, in
69859
+ // screen coordinates: positive is right/down, the same sign the gesture reports.
69860
+ const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
69861
+
69862
+ /**
69863
+ * Builds the `pointerdown` handler a side panel docked to `side` answers with.
69864
+ */
69865
+ const createSwipeToClose = (side) => {
69866
+ const axis = SWIPE_AXIS_BY_SIDE[side];
69867
+ const closeDirection = CLOSE_DIRECTION_BY_SIDE[side];
69868
+
69869
+ return (pointerDownEvent) => {
69870
+ const panelEl = pointerDownEvent.currentTarget;
69871
+ if (panelEl.getAttribute("aria-expanded") !== "true") {
69872
+ return;
69873
+ }
69874
+
69875
+ // Where the panel stands, written on it directly: the gesture reports a
69876
+ // distance in screen coordinates, which is exactly what a translate takes.
69877
+ const paint = (distance) => {
69878
+ panelEl.style.translate =
69879
+ axis === "x" ? `${distance}px 0px` : `0px ${distance}px`;
69880
+ };
69881
+ const restore = () => {
69882
+ panelEl.style.translate = "";
69883
+ panelEl.style.transitionProperty = "";
69884
+ panelEl.style.userSelect = "";
69885
+ };
69886
+ // The resting value is written first and the animation only covers the way
69887
+ // to it: both end on the same number, so nothing is seen changing when the
69888
+ // animation hands the panel back to its own style.
69889
+ const travelTo = (from, to, onArrival) => {
69890
+ const covered = from > to ? from - to : to - from;
69891
+ paint(to);
69892
+ const animation = panelEl.animate(
69893
+ [
69894
+ { translate: translateOf(axis, from) },
69895
+ { translate: translateOf(axis, to) },
69896
+ ],
69897
+ {
69898
+ duration: (covered / sizeOf(panelEl, axis)) * TRAVEL_DURATION,
69899
+ easing: "ease-out",
69900
+ },
69901
+ );
69902
+ animation.finished.then(onArrival, () => {});
69903
+ };
69904
+ const close = (event) => {
69905
+ triggerNaviCommand(panelEl, "--navi-close", event);
69906
+ if (panelEl.getAttribute("aria-expanded") === "true") {
69907
+ // Refused: the panel is staying, so it goes back where it was.
69908
+ travelTo(sizeOf(panelEl, axis) * closeDirection, 0, restore);
69909
+ return;
69910
+ }
69911
+ restore();
69912
+ };
69913
+
69914
+ startDragToTravel(pointerDownEvent, {
69915
+ element: panelEl,
69916
+ axes: axis,
69917
+ onStart: ({ sign, target }) => {
69918
+ if (sign !== closeDirection) {
69919
+ // Pushing the panel further in is not a travel: there is nowhere that
69920
+ // way, and reading it as one would make the panel lean at every
69921
+ // gesture that starts by going the wrong way.
69922
+ return false;
69923
+ }
69924
+ const size = sizeOf(panelEl, axis);
69925
+ if (!size) {
69926
+ return false;
69927
+ }
69928
+ // Something else with a better claim on the gesture: a scroller between
69929
+ // the finger and the panel, with room left that way.
69930
+ if (scrollRoomTowards(target, panelEl, axis, sign)) {
69931
+ return false;
69932
+ }
69933
+ panelEl.style.transitionProperty = "none";
69934
+ panelEl.style.userSelect = "none";
69935
+ // What the first pixels of the gesture may have started selecting is
69936
+ // not a selection, it is the beginning of this travel.
69937
+ window.getSelection().removeAllRanges();
69938
+ // The way out is the only way there is anything: pulling the other way
69939
+ // leans against a wall and comes back.
69940
+ return {
69941
+ size,
69942
+ travelBack: closeDirection > 0,
69943
+ travelOn: closeDirection < 0,
69944
+ };
69945
+ },
69946
+ onPull: ({ pulled }) => {
69947
+ paint(pulled);
69948
+ },
69949
+ onEnd: ({ pulled, size, travels, event }) => {
69950
+ if (travels) {
69951
+ travelTo(pulled, size * closeDirection, () => close(event));
69952
+ return;
69953
+ }
69954
+ travelTo(pulled, 0, restore);
69955
+ },
69956
+ });
69957
+ };
69958
+ };
69959
+
69960
+ const sizeOf = (element, axis) => {
69961
+ const rect = element.getBoundingClientRect();
69962
+ return axis === "x" ? rect.width : rect.height;
69963
+ };
69964
+ const translateOf = (axis, distance) =>
69965
+ axis === "x" ? `${distance}px 0px` : `0px ${distance}px`;
69966
+
69589
69967
  installImportMetaCssBuild(import.meta);/**
69590
69968
  * A drawer docked flush to a viewport (or container) edge, built on top of
69591
69969
  * `Popup`. Sizing, the perpendicular-axis fill, and the flush-edge
@@ -69714,6 +70092,20 @@ const css = /* css */`
69714
70092
  }
69715
70093
  }
69716
70094
 
70095
+ /* What a touch may do on a panel that closes by being pushed back (see
70096
+ swipe_to_close.js): a left/right panel scrolls its own content
70097
+ downwards and travels sideways, so the two never compete and the
70098
+ browser is told to leave the sideways one alone — which also keeps a
70099
+ swipe near the screen edge from being read as "go back" instead. A
70100
+ top/bottom panel travels the way it scrolls: nothing can be given away
70101
+ there without losing the scroll, so the gesture is settled by
70102
+ swipe_to_close.js's own reading of what is still scrollable, and the
70103
+ browser keeps both axes. */
70104
+ &[data-swipe-to-close][navi-side="left"],
70105
+ &[data-swipe-to-close][navi-side="right"] {
70106
+ touch-action: pan-y;
70107
+ }
70108
+
69717
70109
  /* Sticky regardless of side: the panel's own content always stacks
69718
70110
  (and scrolls) top-to-bottom, whether the panel itself is docked to
69719
70111
  the left/right or the top/bottom of the viewport/container — so
@@ -69792,6 +70184,11 @@ const css = /* css */`
69792
70184
  * navigation inside the panel (`focusCapture`) — closing on outside
69793
70185
  * interaction only makes sense paired with not letting focus silently
69794
70186
  * leave the panel first.
70187
+ * @param {boolean} [props.swipeToClose=true] - Pushing the panel back
70188
+ * towards the edge it is docked to closes it: the panel follows the
70189
+ * pointer and finishes leaving (or comes back to rest) when it is
70190
+ * released. Set to `false` for a panel that must only ever be dismissed
70191
+ * deliberately. See `swipe_to_close.js` for when the gesture is claimed.
69795
70192
  * @param {"dialog"|"popover"} [props.mode] - Forwarded to `Popup` — forces
69796
70193
  * one underlying renderer instead of its automatic screen-size
69797
70194
  * resolution. Note that if `Popup` ends up in dialog mode (small screen,
@@ -69817,12 +70214,14 @@ const SidePanel = ({
69817
70214
  minHeight,
69818
70215
  animation,
69819
70216
  closeOnClickOutside = false,
70217
+ swipeToClose = true,
69820
70218
  mode,
69821
70219
  layer = "top",
69822
70220
  className,
69823
70221
  ...rest
69824
70222
  }) => {
69825
70223
  import.meta.css = [css, "@jsenv/navi/src/layout/side_panel.jsx"];
70224
+ const onSwipePointerDown = swipeToClose ? createSwipeToClose(side) : null;
69826
70225
  return jsx(Popup, {
69827
70226
  mode: mode,
69828
70227
  open: open,
@@ -69843,7 +70242,20 @@ const SidePanel = ({
69843
70242
  minHeight: toCssLength(minHeight),
69844
70243
  className: withPropsClassName("navi_side_panel", className),
69845
70244
  "navi-side": side,
70245
+ "data-swipe-to-close": swipeToClose ? "" : undefined
70246
+ // The axis the panel travels on when it is pushed back, said to the
70247
+ // shared gesture layer: it keeps the panel's scrolling from spilling onto
70248
+ // the page, and it is what a box travelling inside the panel reads to
70249
+ // know this axis is already walked (see @jsenv/dom's drag_to_travel).
70250
+ ,
70251
+
70252
+ "data-drag-travel": swipeToClose ? SWIPE_AXIS_BY_SIDE[side] : undefined,
70253
+ "data-travel-by-drag": swipeToClose ? SWIPE_AXIS_BY_SIDE[side] : undefined,
69846
70254
  ...rest,
70255
+ onPointerDown: pointerDownEvent => {
70256
+ rest.onPointerDown?.(pointerDownEvent);
70257
+ onSwipePointerDown?.(pointerDownEvent);
70258
+ },
69847
70259
  style: {
69848
70260
  "--navi-side-panel-width": toCssLength(width, "width"),
69849
70261
  "--navi-side-panel-height": toCssLength(height, "height"),