@jsenv/navi 0.29.35 → 0.29.37

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.
@@ -21855,7 +21855,9 @@ registerNaviCommand("--navi-reset", (source, event) => {
21855
21855
  * - an open popup: it closes. The popup was there for the duration of one
21856
21856
  * decision, and the send just made it. A picker already does this when the
21857
21857
  * send targets the popup itself (executeNaviDefine); a form inside one is the
21858
- * same act, a level down;
21858
+ * same act, a level down. A popup opened from another popup answers for
21859
+ * itself only — say `data-after-send="--navi-send"` on the sending control
21860
+ * to make the decision travel up to the surface holding it;
21859
21861
  * - the document: nothing. A form on a page stays where it is.
21860
21862
  */
21861
21863
  // What follows a send that went through — and it is asked of the button that
@@ -21909,7 +21911,21 @@ registerNaviCommand("--navi-send", (source, event) => {
21909
21911
  if (target.getAttribute("aria-expanded") === "true") {
21910
21912
  return {
21911
21913
  target,
21912
- implementation: () => executeNaviDefine(source, event, target),
21914
+ implementation: () => {
21915
+ const result = executeNaviDefine(source, event, target);
21916
+ // Only what was explicitly asked for: the surface above a popup is not
21917
+ // answered by a decision taken inside it (see resolveAfterSend), so a
21918
+ // popup opened from another popup closes alone unless the send says
21919
+ // otherwise. Triggered from the target — the popup that just closed no
21920
+ // longer holds the source.
21921
+ const afterSend =
21922
+ source.getAttribute("data-after-send") ||
21923
+ target.getAttribute("data-after-send");
21924
+ if (afterSend) {
21925
+ triggerNaviCommand(target, afterSend, event, { optional: true });
21926
+ }
21927
+ return result;
21928
+ },
21913
21929
  };
21914
21930
  }
21915
21931
 
@@ -23384,7 +23400,8 @@ const useUIStateController = (
23384
23400
  controller.id = props.id; // never supposed to change, not supported for now
23385
23401
  controller.name = props.name;
23386
23402
  controller.parentUIStateController = parentUIStateController;
23387
- const { value, hasStateProp, state, stateInitial } = controlInfo;
23403
+ const { value, hasStateProp, state, stateInitial, stateFromSignal } =
23404
+ controlInfo;
23388
23405
  controller.value = value;
23389
23406
  if (hasStateProp) {
23390
23407
  controller.hasStateProp = true;
@@ -23393,9 +23410,27 @@ const useUIStateController = (
23393
23410
  controller.state = state;
23394
23411
  controller.setUIState(state, new CustomEvent("state_prop_change"));
23395
23412
  }
23396
- } else if (controller.hasStateProp) {
23397
- controller.hasStateProp = false;
23398
- controller.state = stateInitial;
23413
+ } else {
23414
+ if (controller.hasStateProp) {
23415
+ controller.hasStateProp = false;
23416
+ controller.state = stateInitial;
23417
+ }
23418
+ if (controlInfo.signal) {
23419
+ // The other half of a bound signal: the control follows it when
23420
+ // something else writes it. Compared against `state` (what the signal
23421
+ // last said), never against the ui state — otherwise typing into an
23422
+ // uncontrolled control would be undone on the next render. A write
23423
+ // coming from the control itself lands here with the value already in
23424
+ // place, and setUIState treats an unchanged state as a no-op.
23425
+ const currentState = controller.state;
23426
+ if (!compareTwoJsValues(stateFromSignal, currentState)) {
23427
+ controller.state = stateFromSignal;
23428
+ controller.setUIState(
23429
+ stateFromSignal,
23430
+ new CustomEvent("state_prop_change"),
23431
+ );
23432
+ }
23433
+ }
23399
23434
  }
23400
23435
  return {
23401
23436
  ref: props.ref,
@@ -23675,10 +23710,9 @@ const useUIGroupStateController = (
23675
23710
  const hasValueProp = Object.hasOwn(props, "value");
23676
23711
  const hasOwnDefaultValueProp = Object.hasOwn(props, "defaultValue");
23677
23712
  // 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.
23713
+ // with the signal's current value as what it starts on. Write-back is handled
23714
+ // by applyState's own boundSignal; the read half is below: children are
23715
+ // placed from it when they register, and again whenever it moves.
23682
23716
  const boundSignal = hasValueProp ? undefined : props.signal;
23683
23717
  const hasDefaultValueProp = hasOwnDefaultValueProp || Boolean(boundSignal);
23684
23718
  const { id, name, value, uiAction } = props;
@@ -24096,6 +24130,7 @@ const useUIGroupStateController = (
24096
24130
  const { controller } = s;
24097
24131
  const prevValue = controller.value;
24098
24132
  const prevHasValueProp = controller.hasValueProp;
24133
+ const prevDefaultValue = controller.defaultValue;
24099
24134
  controller.props = props;
24100
24135
  controller.ref = ref;
24101
24136
  controller.id = id;
@@ -24124,6 +24159,31 @@ const useUIGroupStateController = (
24124
24159
  }
24125
24160
  controller.syncInternalState(value);
24126
24161
  }
24162
+ if (
24163
+ boundSignal &&
24164
+ !hasValueProp &&
24165
+ !compareTwoJsValues(defaultValue, prevDefaultValue)
24166
+ ) {
24167
+ // The signal moved from the outside: the children are placed from it
24168
+ // again, exactly as they were when they registered. Without this a
24169
+ // group would answer a write to its own signal by silently writing its
24170
+ // former value back over it on the next child interaction.
24171
+ const propagateDownEvent = new CustomEvent(
24172
+ "propagate_down_set_ui_state",
24173
+ { detail: {} },
24174
+ );
24175
+ for (const childUIStateController of childUIStateControllerArray) {
24176
+ if (!shouldPropagateStateToChild(childUIStateController)) continue;
24177
+ if (childUIStateController.hasStateProp) continue;
24178
+ const childNewState = controller.distributeChildUIState(
24179
+ defaultValue,
24180
+ childUIStateController,
24181
+ );
24182
+ if (childNewState === CANNOT_DERIVE) continue;
24183
+ childUIStateController.setUIState(childNewState, propagateDownEvent);
24184
+ }
24185
+ controller.syncInternalState(defaultValue);
24186
+ }
24127
24187
 
24128
24188
  return {
24129
24189
  ref,
@@ -25248,6 +25308,13 @@ const createControlInfo = (props, {
25248
25308
  const signal = Object.hasOwn(props, "signal") ? props.signal : undefined;
25249
25309
  // For a checkbox/radio the signal holds the boolean checked state, not the value.
25250
25310
  let signalHoldsChecked = false;
25311
+ // What the signal says the control shows, re-read on every render. A signal
25312
+ // carrying a default of its own seeds `defaultValue` (resolveInputProps), so
25313
+ // such a control is uncontrolled — but the binding still has to work in both
25314
+ // directions: whoever writes the signal from the outside expects the control
25315
+ // to move. This is what the controller re-syncs on (see useUIStateController),
25316
+ // and an emptied signal puts the control back on its suggestion.
25317
+ let stateFromSignal;
25251
25318
  if (controlType === "input") {
25252
25319
  if (typeProp === "checkbox" || typeProp === "radio") {
25253
25320
  statePropName = "checked";
@@ -25259,10 +25326,13 @@ const createControlInfo = (props, {
25259
25326
  stateInitial = props.checked ? value : undefined;
25260
25327
  } else if (props.defaultChecked) {
25261
25328
  // resolveInputProps may seed defaultChecked from a bound signal's
25262
- // default: the control stays uncontrolled and only write-syncs the
25263
- // signal (onUIAction).
25329
+ // default: the control stays uncontrolled and follows the signal
25330
+ // through stateFromSignal.
25264
25331
  hasStateProp = false;
25265
25332
  stateInitial = value;
25333
+ if (signal) {
25334
+ stateFromSignal = signal.value ? value : undefined;
25335
+ }
25266
25336
  } else if (signal) {
25267
25337
  // A bound signal with no resolved default: its live value seeds state.
25268
25338
  hasStateProp = true;
@@ -25289,6 +25359,9 @@ const createControlInfo = (props, {
25289
25359
  // url or set by whoever owns it. Taking the default here would show a
25290
25360
  // suggestion in place of the value on every reload.
25291
25361
  stateInitial = signal && signal.value !== undefined ? signal.value : props.defaultValue;
25362
+ if (signal) {
25363
+ stateFromSignal = stateInitial;
25364
+ }
25292
25365
  } else if (signal) {
25293
25366
  // A plain bound signal with no default (e.g. Wheel): its live value
25294
25367
  // seeds and controls the state.
@@ -25322,6 +25395,9 @@ const createControlInfo = (props, {
25322
25395
  // Same precedence as an input above: the signal's value is the answer,
25323
25396
  // defaultValue only the suggestion to start from.
25324
25397
  stateInitial = signal && signal.value !== undefined ? signal.value : props.defaultValue;
25398
+ if (signal) {
25399
+ stateFromSignal = stateInitial;
25400
+ }
25325
25401
  } else if (signal) {
25326
25402
  hasStateProp = true;
25327
25403
  stateInitial = signal.value;
@@ -25345,6 +25421,7 @@ const createControlInfo = (props, {
25345
25421
  value,
25346
25422
  signal,
25347
25423
  signalHoldsChecked,
25424
+ stateFromSignal,
25348
25425
  readOnlySupported,
25349
25426
  disabledSupported
25350
25427
  };
@@ -36881,12 +36958,19 @@ const css$R = /* css */`
36881
36958
  travelling. */
36882
36959
  .navi_route_travel {
36883
36960
  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;
36961
+
36962
+ /* What a touch may do here only where a touch travels at all
36963
+ (data-travel-by-drag, set from travelByDrag): the axis the pages travel
36964
+ on is taken, the other one is left to the page, so a list still scrolls
36965
+ under the same finger. A box that takes no gesture takes no axis either:
36966
+ it is a plain box around the page, and everything in it scrolls as it
36967
+ would anywhere else. Same reading as SlideContainer's own. */
36968
+ &[data-travel-by-drag="x"] {
36969
+ touch-action: pan-y;
36970
+ }
36971
+ &[data-travel-by-drag="y"] {
36972
+ touch-action: pan-x;
36973
+ }
36890
36974
  }
36891
36975
 
36892
36976
  /* Only while a travel of OURS is playing: everything below changes how the
@@ -37107,6 +37191,11 @@ const css$R = /* css */`
37107
37191
  * page under six entries. A tab pressed is the other case and pushes, which
37108
37192
  * is what its <Link> already does.
37109
37193
  *
37194
+ * Every other prop lands on the box itself (`id`, `data-testid`, `className`,
37195
+ * …), which is how one of these is named: a page can hold several — a row of
37196
+ * sections inside the box the whole application travels in — and the class they
37197
+ * share plus their axis are not enough to tell them apart from the outside.
37198
+ *
37110
37199
  * The pages are cut at the edge of this box while they travel, which is written
37111
37200
  * on the transition's own pseudo-elements — no overflow of the document reaches
37112
37201
  * pictures drawn in the top layer. It needs nothing of the browser beyond view
@@ -46723,12 +46812,13 @@ const NAVI_TYPE_DEFAULTS = {
46723
46812
  */
46724
46813
  /**
46725
46814
  * 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).
46815
+ * control: the control starts there and stays uncontrolled, which is what makes
46816
+ * a form read the value shown as a SUGGESTION rather than as something it
46817
+ * already holds. Uncontrolled here is about what the control HOLDS, not about
46818
+ * whether it follows the signal — the binding stays two-way either way (see
46819
+ * stateFromSignal in control_hooks.jsx). Written once and used by everything
46820
+ * that takes a `signal`, so one signal cannot mean two different things
46821
+ * depending on which control it was handed to.
46732
46822
  */
46733
46823
  const seedDefaultValueFromSignal = (props) => {
46734
46824
  const signalOptions = props.signal?.options;
@@ -46750,11 +46840,11 @@ const seedDefaultValueFromSignal = (props) => {
46750
46840
 
46751
46841
  const resolveInputProps = (props) => {
46752
46842
  // `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.
46843
+ // `createControlInfo` (control_hooks.jsx) reads it to seed the state and to
46844
+ // follow it, and `onUIAction` (ui_state_controller.js) writes user
46845
+ // interactions back into it. Here we only derive input defaults (type/min/max,
46846
+ // defaultValue/defaultChecked) from the signal's `options`, so the control
46847
+ // ends up uncontrolled-with-default while still bound to the signal.
46758
46848
  const signal = props.signal;
46759
46849
  if (signal) {
46760
46850
  const signalOptions = signal.options;
@@ -51371,6 +51461,12 @@ installImportMetaCssBuild(import.meta);const css$y = /* css */`
51371
51461
  --dialog-outline-width: var(--picker-outline-width);
51372
51462
  --dialog-outline-color: var(--picker-outline-color);
51373
51463
 
51464
+ /* No fallback on purpose (same as --popover-max-height above): unset
51465
+ picker props leave these declarations invalid at computed-value
51466
+ time, so the dialog keeps its own ceilings. */
51467
+ --dialog-max-width: var(--picker-dialog-max-width);
51468
+ --dialog-max-height: var(--picker-dialog-max-height);
51469
+
51374
51470
  /* Dialog itself already sizes min-width off --anchor-width — only
51375
51471
  the cursor reset below is picker-specific here. */
51376
51472
  cursor: default; /* Reset pointer cursor within the select */
@@ -51489,7 +51585,13 @@ const PickerCustom = props => {
51489
51585
  ref,
51490
51586
  mode: modeProp,
51491
51587
  open,
51492
- defaultOpen
51588
+ defaultOpen,
51589
+ // What Escape means for this picker. "cancel" (the default) puts back the
51590
+ // value the picker had at open and, for a dialog, goes back in history —
51591
+ // so everything written to the url while it was open goes back too.
51592
+ // "close" makes Escape say the same thing as clicking outside: keep what
51593
+ // was chosen, close the popup.
51594
+ escapeEffect = "cancel"
51493
51595
  } = props;
51494
51596
  // Resolve the id the same way useControlProps does (own id > Field's id > generated id)
51495
51597
  // before computing popupId below, so two Pickers without an explicit id never collide.
@@ -51521,6 +51623,7 @@ const PickerCustom = props => {
51521
51623
  // otherwise leak through PickerContentInsidePopup's own ...rest).
51522
51624
  delete pickerProps.open;
51523
51625
  delete pickerProps.defaultOpen;
51626
+ delete pickerProps.escapeEffect;
51524
51627
  const popupProps = {};
51525
51628
  Object.assign(pickerProps, {
51526
51629
  popupProps,
@@ -51789,11 +51892,12 @@ const PickerCustom = props => {
51789
51892
  if (!openController.opened) {
51790
51893
  return null;
51791
51894
  }
51895
+ const isCancel = escapeEffect === "cancel";
51792
51896
  return {
51793
- name: "escape_to_cancel",
51897
+ name: isCancel ? "escape_to_cancel" : "escape_to_close",
51794
51898
  allowed: () => {
51795
51899
  requestClose(e, {
51796
- isCancel: true
51900
+ isCancel
51797
51901
  });
51798
51902
  e.preventDefault(); // prevent browser from closing the dialog (if any)
51799
51903
  }
@@ -51952,7 +52056,7 @@ const PickerContentInsidePopup = props => {
51952
52056
  animation: animation,
51953
52057
  positionArea: isPopover ? positionArea ?? (popoverMode === "nearby" ? "bottom-start" : "inset(top-left)") : positionArea,
51954
52058
  marginWithAnchor: isPopover ? popoverSpacing : undefined,
51955
- marginWithContainer: marginWithContainer === undefined && isPopover ? popoverSpacing : marginWithContainer ? 10 : marginWithContainer,
52059
+ marginWithContainer: marginWithContainer === undefined && isPopover ? popoverSpacing : marginWithContainer,
51956
52060
  scrollCapture: scrollCapture,
51957
52061
  pointerInteractionOutsideEffect: pointerLock ? "capture" : pointerInteractionOutsideEffect,
51958
52062
  focusCapture: isPopover ? focusCapture : undefined,
@@ -52845,6 +52949,11 @@ installImportMetaCssBuild(import.meta);const css$w = /* css */`
52845
52949
  }
52846
52950
  `;
52847
52951
  const SelectableListMultipleContext = createContext(false);
52952
+ // A row of a selectable list is selectable — the list is what decides, and a
52953
+ // row says nothing unless it wants out (`selectable={false}` on a row that is
52954
+ // only there to be read). Also set to false by a non-selectable list, so a list
52955
+ // nested inside a selectable row does not inherit the outer list's answer.
52956
+ const ListSelectableContext = createContext(false);
52848
52957
  // Interactive variant: manages hover/keyboard/selection state and handles the
52849
52958
  // navi event protocol. When an action is provided it binds the action to ui state
52850
52959
  // and fires it on select. When only uiAction is provided it calls it directly.
@@ -52855,8 +52964,11 @@ const ListSelectableResolver = props => {
52855
52964
  ...props
52856
52965
  });
52857
52966
  }
52858
- return jsx(Next, {
52859
- ...props
52967
+ return jsx(ListSelectableContext.Provider, {
52968
+ value: false,
52969
+ children: jsx(Next, {
52970
+ ...props
52971
+ })
52860
52972
  });
52861
52973
  };
52862
52974
  const ListSelectable = props => {
@@ -53146,17 +53258,27 @@ const ListSelectable = props => {
53146
53258
  children: props.children
53147
53259
  })
53148
53260
  });
53149
- return jsx(SelectableListMultipleContext.Provider, {
53150
- value: multiple,
53151
- children: listVnode
53261
+ return jsx(ListSelectableContext.Provider, {
53262
+ value: true,
53263
+ children: jsx(SelectableListMultipleContext.Provider, {
53264
+ value: multiple,
53265
+ children: listVnode
53266
+ })
53152
53267
  });
53153
53268
  };
53154
53269
  const SelectableRealInputContext = createContext(null);
53155
53270
  const ListItemSelectableResolver = props => {
53156
53271
  const Next = useNextResolver();
53157
- if (props.selectable) {
53272
+ const listSelectable = useContext(ListSelectableContext);
53273
+ // A header/footer row is a title, not a choice — it stays out even in a
53274
+ // selectable list. (A skeleton row never reaches here: its own resolver runs
53275
+ // before this one.)
53276
+ const isHeaderOrFooter = Boolean(props.header || props.footer);
53277
+ const selectable = props.selectable ?? (isHeaderOrFooter || props.role === "presentation" ? false : listSelectable);
53278
+ if (selectable) {
53158
53279
  return jsx(ListItemSelectable, {
53159
- ...props
53280
+ ...props,
53281
+ selectable: true
53160
53282
  });
53161
53283
  }
53162
53284
  return jsx(Next, {
@@ -53182,6 +53304,10 @@ const ListItemSelectable = props => {
53182
53304
  selected,
53183
53305
  pointed,
53184
53306
  selectableArea = "all",
53307
+ // The row's own click handler, kept out of the control props below: those
53308
+ // describe the hidden input that carries the selection, and a caller
53309
+ // writing onClick on a <List.Item> is talking about the row they see.
53310
+ onClick,
53185
53311
  ...rest
53186
53312
  } = props;
53187
53313
  const multiple = useContext(SelectableListMultipleContext);
@@ -53236,6 +53362,7 @@ const ListItemSelectable = props => {
53236
53362
  ...basePseudoState
53237
53363
  },
53238
53364
  ref: props.ref,
53365
+ onClick: onClick,
53239
53366
  selectable: undefined,
53240
53367
  "navi-selectable-area-all": selectableArea === "all" ? "" : undefined,
53241
53368
  children: [jsx(SelectableRealInput, {
@@ -53514,6 +53641,26 @@ const css$v = /* css */`
53514
53641
  --list-border-width-default: 0px;
53515
53642
  }
53516
53643
 
53644
+ /* Same reasoning, for the corners: a dialog squares off whatever corner
53645
+ lands on its container's own (see the data-flush-* rules in dialog.jsx —
53646
+ a bottom sheet from dockedOnTouch squares its two bottom ones). A list
53647
+ drawn right against that corner has to square the same one, otherwise its
53648
+ own radius carves a notch out of the popup's square corner. Direct child
53649
+ only: any deeper and the list is presumably inset from the popup's edge,
53650
+ where its own radius is the right one. */
53651
+ .navi_dialog[data-flush-top][data-flush-left] > .navi_list_container {
53652
+ border-top-left-radius: 0;
53653
+ }
53654
+ .navi_dialog[data-flush-top][data-flush-right] > .navi_list_container {
53655
+ border-top-right-radius: 0;
53656
+ }
53657
+ .navi_dialog[data-flush-bottom][data-flush-right] > .navi_list_container {
53658
+ border-bottom-right-radius: 0;
53659
+ }
53660
+ .navi_dialog[data-flush-bottom][data-flush-left] > .navi_list_container {
53661
+ border-bottom-left-radius: 0;
53662
+ }
53663
+
53517
53664
  .navi_list_container {
53518
53665
  --x-list-border-radius: var(--list-border-radius);
53519
53666
  --x-list-border-width: var(
@@ -56359,7 +56506,8 @@ const LIST_ITEM_STYLE_CSS_VARS = {
56359
56506
  * @param {boolean} [props.selectable]
56360
56507
  * The item participates in selection (radio or checkbox depending on whether
56361
56508
  * the parent List has `multiple`). Requires `value` and typically a
56362
- * <SelectableInput /> child.
56509
+ * <SelectableInput /> child. Inherited from `<List selectable>` — pass
56510
+ * `false` for a row that is only there to be read, and nothing otherwise.
56363
56511
  * @param {any} [props.value]
56364
56512
  * The JS value emitted by the list's action/uiAction when this item is
56365
56513
  * selected. Can be any type (string, number, object…).
@@ -57661,7 +57809,7 @@ const PickerTypeResolver = props => {
57661
57809
  const PickerText = props => {
57662
57810
  const Next = useNextResolver();
57663
57811
  return jsx(Next, {
57664
- icon: jsx(PencilSvg, {}),
57812
+ rightSlotIcon: jsx(PencilSvg, {}),
57665
57813
  ...props
57666
57814
  });
57667
57815
  };
@@ -57740,7 +57888,7 @@ const PickerColor = props => {
57740
57888
  const Next = useNextResolver();
57741
57889
  return jsx(Next, {
57742
57890
  ui: jsx(PickerColorUI, {}),
57743
- icon: jsx(ColorSvg, {}),
57891
+ rightSlotIcon: jsx(ColorSvg, {}),
57744
57892
  type: "color",
57745
57893
  ...props
57746
57894
  });
@@ -57764,7 +57912,7 @@ const PickerDate = props => {
57764
57912
  const Next = useNextResolver();
57765
57913
  return jsx(Next, {
57766
57914
  ui: jsx(PickerDateUI, {}),
57767
- icon: jsx(CalendarSvg, {}),
57915
+ rightSlotIcon: jsx(CalendarSvg, {}),
57768
57916
  ...props,
57769
57917
  type: "date"
57770
57918
  });
@@ -57796,7 +57944,7 @@ const PickerMonth = props => {
57796
57944
  const Next = useNextResolver();
57797
57945
  return jsx(Next, {
57798
57946
  ui: jsx(PickerMonthUI, {}),
57799
- icon: jsx(CalendarSvg, {}),
57947
+ rightSlotIcon: jsx(CalendarSvg, {}),
57800
57948
  ...props,
57801
57949
  type: "month"
57802
57950
  });
@@ -57827,7 +57975,7 @@ const PickerWeek = props => {
57827
57975
  const Next = useNextResolver();
57828
57976
  return jsx(Next, {
57829
57977
  ui: jsx(PickerWeekUI, {}),
57830
- icon: jsx(CalendarSvg, {}),
57978
+ rightSlotIcon: jsx(CalendarSvg, {}),
57831
57979
  ...props,
57832
57980
  type: "week"
57833
57981
  });
@@ -57858,7 +58006,7 @@ const PickerTime = props => {
57858
58006
  const Next = useNextResolver();
57859
58007
  return jsx(Next, {
57860
58008
  ui: jsx(PickerTimeUI, {}),
57861
- icon: jsx(ClockSvg, {}),
58009
+ rightSlotIcon: jsx(ClockSvg, {}),
57862
58010
  ...props,
57863
58011
  type: "time"
57864
58012
  });
@@ -57888,7 +58036,7 @@ const PickerDuration = props => {
57888
58036
  const Next = useNextResolver();
57889
58037
  return jsx(Next, {
57890
58038
  ui: jsx(PickerDurationUI, {}),
57891
- icon: jsx(DurationSvg, {}),
58039
+ rightSlotIcon: jsx(DurationSvg, {}),
57892
58040
  ...props,
57893
58041
  type: "text",
57894
58042
  "navi-input-type": "duration"
@@ -57919,7 +58067,7 @@ const PickerDatetime = props => {
57919
58067
  const Next = useNextResolver();
57920
58068
  return jsx(Next, {
57921
58069
  ui: jsx(PickerDatetimeUI, {}),
57922
- icon: jsx(CalendarSvg, {}),
58070
+ rightSlotIcon: jsx(CalendarSvg, {}),
57923
58071
  ...props,
57924
58072
  type: "datetime-local"
57925
58073
  });
@@ -57948,7 +58096,7 @@ const PickerFile = props => {
57948
58096
  const Next = useNextResolver();
57949
58097
  return jsx(Next, {
57950
58098
  ui: jsx(PickerFileUI, {}),
57951
- icon: jsx(FileSvg, {}),
58099
+ rightSlotIcon: jsx(FileSvg, {}),
57952
58100
  type: "file",
57953
58101
  ...props
57954
58102
  });
@@ -58288,6 +58436,28 @@ installImportMetaCssBuild(import.meta);const css$r = /* css */`
58288
58436
  --x-picker-background-color: transparent;
58289
58437
  --x-picker-icon-color: currentColor;
58290
58438
  }
58439
+ /* discrete: no box at rest, a background on hover — the same word Button
58440
+ uses, and the same drawing. What is read is the value, not the field
58441
+ around it; the chevron in the right slot is what still says it opens. */
58442
+ &[data-variant="discrete"] {
58443
+ --picker-border-width: 0px; /* must carry a unit (px) — used in calc() to offset the custom input overlay */
58444
+ --x-picker-border-color: transparent;
58445
+ --x-picker-background-color: transparent;
58446
+
58447
+ &[data-hover] {
58448
+ --x-picker-border-color: transparent;
58449
+ --x-picker-background-color: color-mix(
58450
+ in srgb,
58451
+ currentColor 8%,
58452
+ transparent
58453
+ );
58454
+ }
58455
+ &[data-readonly],
58456
+ &[data-disabled] {
58457
+ --x-picker-border-color: transparent;
58458
+ --x-picker-background-color: transparent;
58459
+ }
58460
+ }
58291
58461
  &[data-variant="headless"] {
58292
58462
  --x-picker-padding-top: 0;
58293
58463
  --x-picker-padding-right: 0;
@@ -58318,8 +58488,8 @@ const PickerButton = props => {
58318
58488
  const {
58319
58489
  ref,
58320
58490
  variant,
58321
- icon,
58322
- iconSize = "inherit",
58491
+ rightSlotIcon,
58492
+ rightSlotIconSize = "inherit",
58323
58493
  placeholder,
58324
58494
  ui,
58325
58495
  maxLines = 1,
@@ -58364,8 +58534,8 @@ const PickerButton = props => {
58364
58534
  basePseudoState: basePseudoState,
58365
58535
  styleCSSVars: PickerStyleCSSVars,
58366
58536
  variant: undefined,
58367
- icon: undefined,
58368
- iconSize: undefined,
58537
+ rightSlotIcon: undefined,
58538
+ rightSlotIconSize: undefined,
58369
58539
  ui: undefined,
58370
58540
  maxLines: undefined,
58371
58541
  popupWidthFitContent: undefined,
@@ -58502,7 +58672,7 @@ const PickerButton = props => {
58502
58672
  flex: true,
58503
58673
  align: "center",
58504
58674
  children: jsx(Icon, {
58505
- size: iconSize,
58675
+ size: rightSlotIconSize,
58506
58676
  lineOverflow: "allow",
58507
58677
  children: jsx(CloseSvg, {})
58508
58678
  })
@@ -58511,9 +58681,9 @@ const PickerButton = props => {
58511
58681
  // character — a caller asking for a bigger one wants it bigger,
58512
58682
  // not capped at the height of the line it sits on
58513
58683
  jsx(Icon, {
58514
- size: iconSize,
58684
+ size: rightSlotIconSize,
58515
58685
  lineOverflow: "allow",
58516
- children: icon === undefined ? jsx(ChevronDownSvg$1, {}) : icon
58686
+ children: rightSlotIcon === undefined ? jsx(ChevronDownSvg$1, {}) : rightSlotIcon
58517
58687
  })
58518
58688
  })]
58519
58689
  }), jsx(ControlFacadeChildrenWrapper, {
@@ -58604,6 +58774,8 @@ const PickerStyleCSSVars = {
58604
58774
  "borderWidth": "--picker-border-width",
58605
58775
  "borderRadius": "--picker-border-radius",
58606
58776
  "popoverMaxHeight": "--picker-popover-max-height",
58777
+ "dialogMaxWidth": "--picker-dialog-max-width",
58778
+ "dialogMaxHeight": "--picker-dialog-max-height",
58607
58779
  "popupBackgroundColor": "--picker-popup-background-color",
58608
58780
  "popupBorderRadius": "--picker-popup-border-radius",
58609
58781
  "dialogBorderWidth": "--picker-dialog-border-width",
@@ -58683,11 +58855,14 @@ const PickerFirstResolver = props => {
58683
58855
  * popoverMode?: "nearby" | "overlay",
58684
58856
  * positionArea?: string,
58685
58857
  * popupWidthFitContent?: boolean,
58686
- * variant?: "icon" | "headless",
58687
- * icon?: import("ignore:preact").ComponentChildren,
58858
+ * variant?: "icon" | "headless" | "discrete",
58859
+ * rightSlotIcon?: import("ignore:preact").ComponentChildren,
58860
+ * rightSlotIconSize?: number | string,
58688
58861
  * maxLines?: number,
58689
58862
  * slotSpacing?: number | string,
58690
58863
  * popoverMaxHeight?: number | string,
58864
+ * dialogMaxWidth?: number | string,
58865
+ * dialogMaxHeight?: number | string,
58691
58866
  * popupBackgroundColor?: string,
58692
58867
  * popupBorderRadius?: number | string,
58693
58868
  * clearable?: boolean,
@@ -58695,6 +58870,9 @@ const PickerFirstResolver = props => {
58695
58870
  * dialogExpand?: boolean,
58696
58871
  * dialogExpandX?: boolean,
58697
58872
  * dialogExpandY?: boolean,
58873
+ * marginWithContainer?: number | string,
58874
+ * escapeEffect?: "cancel" | "close",
58875
+ * pointerInteractionOutsideEffect?: "close" | "cancel" | "capture",
58698
58876
  * ref?: import("ignore:preact").RefObject<HTMLElement>,
58699
58877
  * [key: string]: any,
58700
58878
  * }>}
@@ -58714,6 +58892,13 @@ const PickerFirstResolver = props => {
58714
58892
  * @param {boolean} [popupWidthFitContent] By default the popup is at least as
58715
58893
  * wide as the trigger. Set this to let the content size it instead, so a
58716
58894
  * popup narrower than the trigger stays narrow.
58895
+ * @param {import("ignore:preact").ComponentChildren} [rightSlotIcon] What the right
58896
+ * slot draws in place of the chevron. It is the whole slot, not an addition
58897
+ * to it: the picker then no longer says on its own that it opens, so pass
58898
+ * something that does.
58899
+ * @param {number|string} [rightSlotIconSize="inherit"] How big what sits in the
58900
+ * right slot is drawn — the chevron, a `rightSlotIcon`, or the clear button's
58901
+ * cross. "inherit" takes the picker's own font size.
58717
58902
  * @param {number|string} [slotSpacing] Gap kept between what sits in the right
58718
58903
  * slot (the chevron, or the clear button) and the picker's own edge — same
58719
58904
  * prop name Input uses for its own slots. Accepts a spacing token ("s",
@@ -58721,6 +58906,25 @@ const PickerFirstResolver = props => {
58721
58906
  * horizontal padding.
58722
58907
  * @param {number|string} [popoverMaxHeight] Soft cap on the popover's height
58723
58908
  * (default 300px). The popover shrinks below it when space is tight.
58909
+ * @param {number|string} [dialogMaxWidth] Ceiling on the dialog's width, the
58910
+ * one `dialogExpand`/`dialogExpandX` grows up to — what makes an expanded
58911
+ * dialog a large sheet rather than a full screen. Capped in turn by the
58912
+ * container minus `marginWithContainer`.
58913
+ * @param {number|string} [dialogMaxHeight] Same, on the height.
58914
+ * @param {"cancel"|"close"} [escapeEffect="cancel"] What Escape does to an open
58915
+ * picker. "cancel" puts back the value the picker had at open, and a dialog
58916
+ * picker also goes back in history — so anything written to the url while it
58917
+ * was open (a route `stateSignal`, a search param) goes back with it. "close"
58918
+ * makes Escape say what clicking outside says: keep what was chosen, close.
58919
+ * @param {"close"|"cancel"|"capture"} [pointerInteractionOutsideEffect="close"]
58920
+ * What a click outside the popup does: close and keep ("close"), close and
58921
+ * put back the value at open ("cancel"), or nothing at all ("capture").
58922
+ * @param {number|string} [marginWithContainer] Minimum gap kept between the
58923
+ * popup and the edges of what contains it (the viewport, or the picker's own
58924
+ * positioned ancestor for `popupLayer="local"`). Caps the popup's size as
58925
+ * well as its placement, so what an expanded dialog leaves visible around
58926
+ * itself is set here. Defaults to `popoverSpacing` in popover mode, and to
58927
+ * Dialog's own 3vvw in dialog mode.
58724
58928
  */
58725
58929
  const Picker = createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
58726
58930
  Picker.UI = PickerDefaultUI;
@@ -60540,12 +60744,26 @@ const usePlaceholderHeight = (ref, placeholder) => {
60540
60744
  textareaEl.style.setProperty("--x-textarea-placeholder-height", `${contentHeight}px`);
60541
60745
  };
60542
60746
  measure();
60747
+ // Measuring writes the variable that sets this element's own height, and
60748
+ // mutating layout from inside a resize callback is what makes the browser
60749
+ // report "ResizeObserver loop completed with undelivered notifications".
60750
+ // So the write waits for the frame that resize produced.
60751
+ let measureFrame = null;
60752
+ const requestMeasure = () => {
60753
+ if (measureFrame !== null) {
60754
+ return;
60755
+ }
60756
+ measureFrame = requestAnimationFrame(() => {
60757
+ measureFrame = null;
60758
+ measure();
60759
+ });
60760
+ };
60543
60761
  // The placeholder wraps against the available width, so a new width is a
60544
60762
  // new number of lines. Height changes are ignored: this measure is what
60545
60763
  // causes them, and reacting to them would be reacting to ourselves.
60546
60764
  const resizeObserver = new ResizeObserver(() => {
60547
60765
  if (textareaEl.clientWidth !== widthMeasured) {
60548
- measure();
60766
+ requestMeasure();
60549
60767
  }
60550
60768
  });
60551
60769
  resizeObserver.observe(textareaEl);
@@ -60558,6 +60776,9 @@ const usePlaceholderHeight = (ref, placeholder) => {
60558
60776
  };
60559
60777
  textareaEl.addEventListener("input", onInput);
60560
60778
  return () => {
60779
+ if (measureFrame !== null) {
60780
+ cancelAnimationFrame(measureFrame);
60781
+ }
60561
60782
  resizeObserver.disconnect();
60562
60783
  textareaEl.removeEventListener("input", onInput);
60563
60784
  };
@@ -69586,6 +69807,150 @@ const ViewportLayout = props => {
69586
69807
  });
69587
69808
  };
69588
69809
 
69810
+ /**
69811
+ * Pushing a side panel back the way it came in closes it: the panel follows
69812
+ * the finger while it travels, and letting go either finishes the departure or
69813
+ * brings it back to rest — whichever the travel was already heading to.
69814
+ *
69815
+ * Reading the pointer is not done here: when a press becomes a gesture, which
69816
+ * axis it leans on, how fast it was going when it was let go, who else has a
69817
+ * claim on it (a field, a scroller with room left, a box inside the panel that
69818
+ * travels the same way) is one gesture system for the whole codebase
69819
+ * (@jsenv/dom's startDragToTravel). A panel closing is a travel of exactly one
69820
+ * box, towards the edge it is docked to and nowhere else — which is all this
69821
+ * file has to say.
69822
+ *
69823
+ * The release travel is driven here (Web Animations) rather than handed to the
69824
+ * exit transition the `animation` prop may install: its pace is what is left to
69825
+ * cover, which a CSS transition written for a full-size travel cannot know, and
69826
+ * two movements on the same property would contradict each other. So CSS
69827
+ * transitions are suppressed on the panel for the whole gesture, release
69828
+ * included, and restored once the panel has either left or come back.
69829
+ */
69830
+
69831
+
69832
+ // What a full-size release travel takes; a shorter one takes proportionally
69833
+ // less, so the panel keeps the same speed whenever the finger let go.
69834
+ const TRAVEL_DURATION = 220;
69835
+
69836
+ // Which way a panel docked to each side travels — read by the panel itself to
69837
+ // say so in the DOM, which is how a box travelling INSIDE it (a row of slides,
69838
+ // a route travel) knows that axis is already walked.
69839
+ const SWIPE_AXIS_BY_SIDE = {
69840
+ left: "x",
69841
+ right: "x",
69842
+ top: "y",
69843
+ bottom: "y",
69844
+ };
69845
+ // Which way the finger pushes to send the panel back where it came from, in
69846
+ // screen coordinates: positive is right/down, the same sign the gesture reports.
69847
+ const CLOSE_DIRECTION_BY_SIDE = { left: -1, right: 1, top: -1, bottom: 1 };
69848
+
69849
+ /**
69850
+ * Builds the `pointerdown` handler a side panel docked to `side` answers with.
69851
+ */
69852
+ const createSwipeToClose = (side) => {
69853
+ const axis = SWIPE_AXIS_BY_SIDE[side];
69854
+ const closeDirection = CLOSE_DIRECTION_BY_SIDE[side];
69855
+
69856
+ return (pointerDownEvent) => {
69857
+ const panelEl = pointerDownEvent.currentTarget;
69858
+ if (panelEl.getAttribute("aria-expanded") !== "true") {
69859
+ return;
69860
+ }
69861
+
69862
+ // Where the panel stands, written on it directly: the gesture reports a
69863
+ // distance in screen coordinates, which is exactly what a translate takes.
69864
+ const paint = (distance) => {
69865
+ panelEl.style.translate =
69866
+ axis === "x" ? `${distance}px 0px` : `0px ${distance}px`;
69867
+ };
69868
+ const restore = () => {
69869
+ panelEl.style.translate = "";
69870
+ panelEl.style.transitionProperty = "";
69871
+ panelEl.style.userSelect = "";
69872
+ };
69873
+ // The resting value is written first and the animation only covers the way
69874
+ // to it: both end on the same number, so nothing is seen changing when the
69875
+ // animation hands the panel back to its own style.
69876
+ const travelTo = (from, to, onArrival) => {
69877
+ const covered = from > to ? from - to : to - from;
69878
+ paint(to);
69879
+ const animation = panelEl.animate(
69880
+ [
69881
+ { translate: translateOf(axis, from) },
69882
+ { translate: translateOf(axis, to) },
69883
+ ],
69884
+ {
69885
+ duration: (covered / sizeOf(panelEl, axis)) * TRAVEL_DURATION,
69886
+ easing: "ease-out",
69887
+ },
69888
+ );
69889
+ animation.finished.then(onArrival, () => {});
69890
+ };
69891
+ const close = (event) => {
69892
+ triggerNaviCommand(panelEl, "--navi-close", event);
69893
+ if (panelEl.getAttribute("aria-expanded") === "true") {
69894
+ // Refused: the panel is staying, so it goes back where it was.
69895
+ travelTo(sizeOf(panelEl, axis) * closeDirection, 0, restore);
69896
+ return;
69897
+ }
69898
+ restore();
69899
+ };
69900
+
69901
+ startDragToTravel(pointerDownEvent, {
69902
+ element: panelEl,
69903
+ axes: axis,
69904
+ onStart: ({ sign, target }) => {
69905
+ if (sign !== closeDirection) {
69906
+ // Pushing the panel further in is not a travel: there is nowhere that
69907
+ // way, and reading it as one would make the panel lean at every
69908
+ // gesture that starts by going the wrong way.
69909
+ return false;
69910
+ }
69911
+ const size = sizeOf(panelEl, axis);
69912
+ if (!size) {
69913
+ return false;
69914
+ }
69915
+ // Something else with a better claim on the gesture: a scroller between
69916
+ // the finger and the panel, with room left that way.
69917
+ if (scrollRoomTowards(target, panelEl, axis, sign)) {
69918
+ return false;
69919
+ }
69920
+ panelEl.style.transitionProperty = "none";
69921
+ panelEl.style.userSelect = "none";
69922
+ // What the first pixels of the gesture may have started selecting is
69923
+ // not a selection, it is the beginning of this travel.
69924
+ window.getSelection().removeAllRanges();
69925
+ // The way out is the only way there is anything: pulling the other way
69926
+ // leans against a wall and comes back.
69927
+ return {
69928
+ size,
69929
+ travelBack: closeDirection > 0,
69930
+ travelOn: closeDirection < 0,
69931
+ };
69932
+ },
69933
+ onPull: ({ pulled }) => {
69934
+ paint(pulled);
69935
+ },
69936
+ onEnd: ({ pulled, size, travels, event }) => {
69937
+ if (travels) {
69938
+ travelTo(pulled, size * closeDirection, () => close(event));
69939
+ return;
69940
+ }
69941
+ travelTo(pulled, 0, restore);
69942
+ },
69943
+ });
69944
+ };
69945
+ };
69946
+
69947
+ const sizeOf = (element, axis) => {
69948
+ const rect = element.getBoundingClientRect();
69949
+ return axis === "x" ? rect.width : rect.height;
69950
+ };
69951
+ const translateOf = (axis, distance) =>
69952
+ axis === "x" ? `${distance}px 0px` : `0px ${distance}px`;
69953
+
69589
69954
  installImportMetaCssBuild(import.meta);/**
69590
69955
  * A drawer docked flush to a viewport (or container) edge, built on top of
69591
69956
  * `Popup`. Sizing, the perpendicular-axis fill, and the flush-edge
@@ -69714,6 +70079,20 @@ const css = /* css */`
69714
70079
  }
69715
70080
  }
69716
70081
 
70082
+ /* What a touch may do on a panel that closes by being pushed back (see
70083
+ swipe_to_close.js): a left/right panel scrolls its own content
70084
+ downwards and travels sideways, so the two never compete and the
70085
+ browser is told to leave the sideways one alone — which also keeps a
70086
+ swipe near the screen edge from being read as "go back" instead. A
70087
+ top/bottom panel travels the way it scrolls: nothing can be given away
70088
+ there without losing the scroll, so the gesture is settled by
70089
+ swipe_to_close.js's own reading of what is still scrollable, and the
70090
+ browser keeps both axes. */
70091
+ &[data-swipe-to-close][navi-side="left"],
70092
+ &[data-swipe-to-close][navi-side="right"] {
70093
+ touch-action: pan-y;
70094
+ }
70095
+
69717
70096
  /* Sticky regardless of side: the panel's own content always stacks
69718
70097
  (and scrolls) top-to-bottom, whether the panel itself is docked to
69719
70098
  the left/right or the top/bottom of the viewport/container — so
@@ -69792,6 +70171,11 @@ const css = /* css */`
69792
70171
  * navigation inside the panel (`focusCapture`) — closing on outside
69793
70172
  * interaction only makes sense paired with not letting focus silently
69794
70173
  * leave the panel first.
70174
+ * @param {boolean} [props.swipeToClose=true] - Pushing the panel back
70175
+ * towards the edge it is docked to closes it: the panel follows the
70176
+ * pointer and finishes leaving (or comes back to rest) when it is
70177
+ * released. Set to `false` for a panel that must only ever be dismissed
70178
+ * deliberately. See `swipe_to_close.js` for when the gesture is claimed.
69795
70179
  * @param {"dialog"|"popover"} [props.mode] - Forwarded to `Popup` — forces
69796
70180
  * one underlying renderer instead of its automatic screen-size
69797
70181
  * resolution. Note that if `Popup` ends up in dialog mode (small screen,
@@ -69817,12 +70201,14 @@ const SidePanel = ({
69817
70201
  minHeight,
69818
70202
  animation,
69819
70203
  closeOnClickOutside = false,
70204
+ swipeToClose = true,
69820
70205
  mode,
69821
70206
  layer = "top",
69822
70207
  className,
69823
70208
  ...rest
69824
70209
  }) => {
69825
70210
  import.meta.css = [css, "@jsenv/navi/src/layout/side_panel.jsx"];
70211
+ const onSwipePointerDown = swipeToClose ? createSwipeToClose(side) : null;
69826
70212
  return jsx(Popup, {
69827
70213
  mode: mode,
69828
70214
  open: open,
@@ -69843,7 +70229,20 @@ const SidePanel = ({
69843
70229
  minHeight: toCssLength(minHeight),
69844
70230
  className: withPropsClassName("navi_side_panel", className),
69845
70231
  "navi-side": side,
70232
+ "data-swipe-to-close": swipeToClose ? "" : undefined
70233
+ // The axis the panel travels on when it is pushed back, said to the
70234
+ // shared gesture layer: it keeps the panel's scrolling from spilling onto
70235
+ // the page, and it is what a box travelling inside the panel reads to
70236
+ // know this axis is already walked (see @jsenv/dom's drag_to_travel).
70237
+ ,
70238
+
70239
+ "data-drag-travel": swipeToClose ? SWIPE_AXIS_BY_SIDE[side] : undefined,
70240
+ "data-travel-by-drag": swipeToClose ? SWIPE_AXIS_BY_SIDE[side] : undefined,
69846
70241
  ...rest,
70242
+ onPointerDown: pointerDownEvent => {
70243
+ rest.onPointerDown?.(pointerDownEvent);
70244
+ onSwipePointerDown?.(pointerDownEvent);
70245
+ },
69847
70246
  style: {
69848
70247
  "--navi-side-panel-width": toCssLength(width, "width"),
69849
70248
  "--navi-side-panel-height": toCssLength(height, "height"),