@jsenv/navi 0.29.167 → 0.29.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, isTouchDrivenEvent, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, scrollIntoViewThroughScrollables, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, releaseWheelGesture, getScrollIntoViewScopedOffsets, wheelGestureIsTakenFrom, claimWheelGesture, scrollIntoViewScoped, initFocusGroup, isTouchDrivenEvent, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, isPressDisputedByDrag, canScroll, measureWidestChildRow, performTabNavigation, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { chainEvent, clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
10
10
  import { isValidElement, h, Fragment, createContext, render, toChildArray, options, cloneElement } from "preact";
@@ -7284,7 +7284,9 @@ const openCallout = (message, {
7284
7284
  // that whole window: by the time anything measures anchorElement, it's
7285
7285
  // already at its final position, so there's no transient frame left to
7286
7286
  // latch onto.
7287
- anchorElement.scrollIntoView({
7287
+ // Through the boxes that scroll and none of the ones that only clip: an
7288
+ // `overflow: hidden` card takes a scroll no user can give back.
7289
+ scrollIntoViewThroughScrollables(anchorElement, {
7288
7290
  behavior: "instant",
7289
7291
  block: "nearest"
7290
7292
  });
@@ -8276,13 +8278,20 @@ const formatCompactNumber = (value, lang) => {
8276
8278
  * Formats a date as a human-readable day string.
8277
8279
  *
8278
8280
  * @param {Date} date
8279
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"numeric" }} [options]
8281
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"numeric"|{ weekday?: "long"|"short"|"narrow", month?: "long"|"short"|"narrow"|"numeric" } }} [options]
8282
+ * A string spells the weekday and the month the same way. An object spells
8283
+ * them apart, each key defaulting to `"long"`: a narrow card usually wants
8284
+ * the weekday whole (it is the reading anchor) and the month abbreviated (it
8285
+ * is where the characters are — "septembre" is 9 of them, "sept." reads the
8286
+ * same). `"numeric"` stays a string-only spelling: it drops the weekday and
8287
+ * writes the whole date in digits.
8280
8288
  *
8281
8289
  * @example
8282
8290
  * formatDay(new Date(), { lang: "fr" }) // "lundi 11 mai" (long, default)
8283
8291
  * formatDay(new Date(), { lang: "fr", format: "short" }) // "lun. 11 mai"
8284
8292
  * formatDay(new Date(), { lang: "fr", format: "narrow" }) // "lu. 11 mai"
8285
8293
  * formatDay(new Date(), { lang: "fr", format: "numeric" }) // "11/05/2026"
8294
+ * formatDay(new Date(), { lang: "fr", format: { weekday: "long", month: "short" } }) // "mercredi 2 sept."
8286
8295
  */
8287
8296
  const formatDay = (
8288
8297
  date,
@@ -8295,10 +8304,12 @@ const formatDay = (
8295
8304
  year: "numeric",
8296
8305
  }).format(date);
8297
8306
  }
8307
+ const { weekday = "long", month = "long" } =
8308
+ typeof format === "string" ? { weekday: format, month: format } : format;
8298
8309
  return new Intl.DateTimeFormat(lang, {
8299
- weekday: format, // "long", "short", or "narrow"
8310
+ weekday, // "long", "short", or "narrow"
8300
8311
  day: "numeric",
8301
- month: format,
8312
+ month,
8302
8313
  }).format(date);
8303
8314
  };
8304
8315
 
@@ -9909,7 +9920,7 @@ const formatDateIso = (iso, inputType) => {
9909
9920
  const locale = languagesSignal.value;
9910
9921
  if (inputType === "month") {
9911
9922
  const date = new Date(`${iso}-01T00:00:00`);
9912
- return formatMonth(date, locale);
9923
+ return formatMonth(date, { lang: locale });
9913
9924
  }
9914
9925
  // date, week, datetime-local: extract YYYY-MM-DD part and parse as local date
9915
9926
  const isoMatch = /^(\d{4}-\d{2}-\d{2})/.exec(iso);
@@ -9921,7 +9932,7 @@ const formatDateIso = (iso, inputType) => {
9921
9932
  if (isNaN(date.getTime())) {
9922
9933
  return iso;
9923
9934
  }
9924
- return formatDay(date, locale);
9935
+ return formatDay(date, { lang: locale });
9925
9936
  };
9926
9937
 
9927
9938
  const READONLY_CONSTRAINT = {
@@ -11045,6 +11056,12 @@ installImportMetaCssBuild(import.meta);/**
11045
11056
  * wants the right click to do what the hold does says so, with `contextmenu`
11046
11057
  * beside it.
11047
11058
  *
11059
+ * It DOES take the selection, and so does a swipe: nothing under either is text
11060
+ * to select, because the browser answers that same press by selecting the word
11061
+ * under the thumb and leaves it selected once its menu is gone. What never
11062
+ * answered the press keeps its text — a field, a popover, a dialog (see the
11063
+ * stylesheet below).
11064
+ *
11048
11065
  * A hold CAN open a popup while the finger is still down — a menu appearing under
11049
11066
  * a waiting finger, which is the native gesture. navi's Popover is `popover="manual"`
11050
11067
  * and owns its own dismissal, so the `pointerup` that ends the press is not read as
@@ -11129,6 +11146,14 @@ import.meta.css = [/* css */`@property --swipe-progress {
11129
11146
  -webkit-touch-callout: none;
11130
11147
  }
11131
11148
 
11149
+ [data-longpress], [data-swipe] {
11150
+ user-select: none;
11151
+ }
11152
+
11153
+ :is([data-longpress], [data-swipe]) :is([data-drag-ignore], [popover], dialog), :is([data-longpress], [data-swipe]) :is(input:not([data-press-only]), textarea), :is([data-longpress], [data-swipe]) :is([contenteditable=""], [contenteditable="true"]) {
11154
+ user-select: text;
11155
+ }
11156
+
11132
11157
  [data-swiping="left"], [data-swiping="right"] {
11133
11158
  translate: var(--swipe-pulled) 0;
11134
11159
  }
@@ -18925,9 +18950,15 @@ const getHowToHandleStyleProp = (name) => {
18925
18950
  }
18926
18951
  return getStyle;
18927
18952
  };
18928
- const prepareStyleValue = (existingValue, value, name, styleContext) => {
18953
+ // The css value `name` would take, written in the scale that style is read in:
18954
+ // "s" on a spacing style is a length, "s" on a typo style is a font size, and a
18955
+ // bare number gets the unit the style expects.
18956
+ const stringifyStyleValue = (value, name, styleContext) => {
18929
18957
  const stringifier = getStringifier(name);
18930
- const cssValue = stringifier(value, name, styleContext);
18958
+ return stringifier(value, name, styleContext);
18959
+ };
18960
+ const prepareStyleValue = (existingValue, value, name, styleContext) => {
18961
+ const cssValue = stringifyStyleValue(value, name, styleContext);
18931
18962
  const mergedValue = mergeOneStyle(existingValue, cssValue, name, "css");
18932
18963
  return mergedValue;
18933
18964
  };
@@ -20963,6 +20994,15 @@ import.meta.css = [/* css */`[data-scrollable] > .navi_loading_outline_wrapper,
20963
20994
  const PSEUDO_CLASSES_DEFAULT = [];
20964
20995
  const PSEUDO_ELEMENTS_DEFAULT = [];
20965
20996
  const STYLE_CSS_VARS_DEFAULT = {};
20997
+ // An entry of styleCSSVars is the css variable the prop writes into, alone when
20998
+ // the prop is named after a css style — Box then already knows how to read its
20999
+ // value — or paired with the style whose values it borrows when it is not.
21000
+ const readCSSVarEntry = entry => {
21001
+ if (Array.isArray(entry)) {
21002
+ return entry;
21003
+ }
21004
+ return [entry, null];
21005
+ };
20966
21006
  // When only pseudoStateSelector is set (no visualSelector), the box owns its
20967
21007
  // visual identity. Only event handlers and these explicit props are forwarded
20968
21008
  // to the inner semantic/interactive child element.
@@ -20973,7 +21013,7 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
20973
21013
  * as?: string,
20974
21014
  * className?: string,
20975
21015
  * style?: import("ignore:preact").JSX.CSSProperties & { [pseudo: string]: import("ignore:preact").JSX.CSSProperties },
20976
- * styleCSSVars?: { [stylePropName: string]: string },
21016
+ * styleCSSVars?: { [propName: string]: string | [string, string] },
20977
21017
  * inline?: boolean,
20978
21018
  * block?: boolean,
20979
21019
  * flex?: "x" | "y" | boolean,
@@ -21334,7 +21374,7 @@ const computeBox = (props, parentBoxFlow) => {
21334
21374
  const canForwardToChild = hasChildUsingForwardedProps;
21335
21375
  const addStyle = (value, name, styleContext, stylesTarget) => {
21336
21376
  const mergedValue = prepareStyleValue(stylesTarget[name], value, name, styleContext);
21337
- const cssVar = styleContext.styleCSSVars[name];
21377
+ const [cssVar] = readCSSVarEntry(styleContext.styleCSSVars[name]);
21338
21378
  if (cssVar) {
21339
21379
  addCSSVar(mergedValue, cssVar, stylesTarget);
21340
21380
  if (name === "borderRadius" && value === "inherit") {
@@ -21455,10 +21495,18 @@ const computeBox = (props, parentBoxFlow) => {
21455
21495
  }
21456
21496
  return;
21457
21497
  }
21458
- const cssVarName = styleCSSVars[name];
21459
- if (cssVarName) {
21498
+ const cssVarEntry = styleCSSVars[name];
21499
+ if (cssVarEntry) {
21460
21500
  if (value !== undefined) {
21461
- addCSSVar(value, cssVarName, boxStylesTarget);
21501
+ const [cssVarName, valueStyleName] = readCSSVarEntry(cssVarEntry);
21502
+ const cssValue = valueStyleName ? stringifyStyleValue(value, valueStyleName, styleContext) : value;
21503
+ if (isSizeSpacingKey(cssValue)) {
21504
+ // A size keyword reaching a custom property stays that word: every
21505
+ // declaration reading the variable is then invalid, drops back to
21506
+ // its initial value, and says nothing about it.
21507
+ console.warn(`"${name}" cannot take the size keyword "${cssValue}": it goes into ${cssVarName} as-is, which makes every declaration reading that variable invalid. Pass "${stringifySpacingStyle(cssValue)}" instead.`);
21508
+ }
21509
+ addCSSVar(cssValue, cssVarName, boxStylesTarget);
21462
21510
  }
21463
21511
  return;
21464
21512
  }
@@ -31253,7 +31301,32 @@ const anyMatchingRouteSignal = (routes) => {
31253
31301
  * when that is not the element asking: a menu opened by a press belongs at the
31254
31302
  * point the press happened, and the row that was pressed is not that point.
31255
31303
  */
31256
- const triggerNaviCommand = (
31304
+ const triggerNaviCommand = (element, command, event, options) => {
31305
+ const run = resolveNaviCommand(element, command, event, options);
31306
+ return run ? run() : false;
31307
+ };
31308
+
31309
+ /**
31310
+ * The same command, in its two moments: WHAT it aims at, decided here and now,
31311
+ * and what it then does, in the function returned. One caller needs them apart
31312
+ * — a button whose command waits for its own action (see the command trigger in
31313
+ * ui_state_controller.js) — and it needs it because resolution reads the DOM
31314
+ * AROUND the source: the closest expandable, the parent control, the slide it
31315
+ * stands in. An action that succeeds has usually re-rendered the tree it was
31316
+ * asked from, which is what an action is for, so by then the source is
31317
+ * detached: `closest` walks a subtree the popup is no longer in, and a command
31318
+ * that had a perfectly good target at the press finds none and is dropped with
31319
+ * a warning — the popup stays open over a request that went through.
31320
+ *
31321
+ * Freezing the target at the press is also what one means by it: the popup
31322
+ * pressed in is the popup to close, even when the press is what emptied it.
31323
+ * What the command DOES stays live — every handler reads the state it needs
31324
+ * inside its implementation, never while resolving.
31325
+ *
31326
+ * Returns undefined when there is nothing to run (unknown command, no target).
31327
+ * See triggerNaviCommand's jsdoc for the parameters.
31328
+ */
31329
+ const resolveNaviCommand = (
31257
31330
  element,
31258
31331
  command,
31259
31332
  event,
@@ -31268,7 +31341,7 @@ const triggerNaviCommand = (
31268
31341
  NAVI_COMMANDS[command] || NAVI_COMMANDS[commandName(command)];
31269
31342
  if (!naviCommand) {
31270
31343
  console.warn(`Unknown command "${command}"`);
31271
- return false;
31344
+ return undefined;
31272
31345
  }
31273
31346
  // Check for explicit HTML target overrides early so a misconfigured commandfor
31274
31347
  // attribute (id not found) aborts immediately rather than silently falling back
@@ -31276,7 +31349,7 @@ const triggerNaviCommand = (
31276
31349
  const explicitTarget = resolveExplicitTarget(element);
31277
31350
  if (explicitTarget === null) {
31278
31351
  // attribute was present but target not found — already warned inside resolveExplicitTarget
31279
- return false;
31352
+ return undefined;
31280
31353
  }
31281
31354
  const execute = naviCommand.commandHandler(element, event, {
31282
31355
  // Whatever followed the colon: "--navi-go-to-slide:edit" → "edit".
@@ -31289,37 +31362,40 @@ const triggerNaviCommand = (
31289
31362
  });
31290
31363
  if (!execute) {
31291
31364
  if (optional) {
31292
- return false;
31365
+ return undefined;
31293
31366
  }
31294
31367
  console.warn(
31295
31368
  `"${command}" triggered on element but no suitable target found`,
31296
31369
  element,
31297
31370
  );
31298
- return false;
31371
+ return undefined;
31299
31372
  }
31300
31373
  const { target, implementation } = execute;
31301
- // The event is how the target takes part in what it was asked to do (a popup
31302
- // closing on --navi-close also has its own things to do); the command itself
31303
- // is the implementation, and it must not depend on someone listening. Nobody
31304
- // does when the target has left the document — a trigger whose own action
31305
- // took it away, which is the shape of "delete this, then leave the page it
31306
- // was on" (see runWhenActionSucceeded in rules/control_action.js): the
31307
- // command is decided while the trigger is there and runs once the action has
31308
- // succeeded, by which time the row it stood in is gone. So the command runs
31309
- // here rather than being lost in silence.
31310
- const detail = {
31311
- command,
31312
- event,
31313
- source: element,
31314
- implementation,
31315
- value,
31316
- answered: false,
31374
+ return () => {
31375
+ // The event is how the target takes part in what it was asked to do (a
31376
+ // popup closing on --navi-close also has its own things to do); the command
31377
+ // itself is the implementation, and it must not depend on someone
31378
+ // listening. Nobody does when the target has left the document — a trigger
31379
+ // whose own action took it away, which is the shape of "delete this, then
31380
+ // leave the page it was on" (see runWhenActionSucceeded in
31381
+ // rules/control_action.js): the command was decided above, while the
31382
+ // trigger was there, and runs once the action has succeeded, by which time
31383
+ // the row it stood in is gone. So the command runs here rather than being
31384
+ // lost in silence.
31385
+ const detail = {
31386
+ command,
31387
+ event,
31388
+ source: element,
31389
+ implementation,
31390
+ value,
31391
+ answered: false,
31392
+ };
31393
+ const dispatched = dispatchCustomEvent(target, "navi_command", detail);
31394
+ if (!detail.answered) {
31395
+ implementation();
31396
+ }
31397
+ return dispatched;
31317
31398
  };
31318
- const dispatched = dispatchCustomEvent(target, "navi_command", detail);
31319
- if (!detail.answered) {
31320
- implementation();
31321
- }
31322
- return dispatched;
31323
31399
  };
31324
31400
 
31325
31401
  // Returns the target explicitly declared via HTML attributes (commandfor / navi-command-target),
@@ -33731,18 +33807,26 @@ const useUIStateController = (
33731
33807
  debugUIState(
33732
33808
  `triggering command "${command}" for "${controlType}"`,
33733
33809
  );
33734
- const runCommand = () => {
33735
- triggerNaviCommand(element, command, e);
33736
- };
33737
33810
  // What the press means may not be due yet: a button with an
33738
33811
  // action of its own runs the work first and lets its command
33739
33812
  // follow only once that work succeeded (see control_hooks).
33740
33813
  // The command is handed over rather than run; nobody claiming
33741
33814
  // it means now.
33815
+ //
33816
+ // Only the RUN waits. What the command aims at is decided here,
33817
+ // while the button is still in the document, because that is
33818
+ // what resolving reads — the popup around it, the control above
33819
+ // it — and the action it waits for is likely to re-render that
33820
+ // away before it succeeds. Resolved late, the command would
33821
+ // find nothing to aim at and be dropped (see
33822
+ // resolveNaviCommand).
33742
33823
  if (controller.commandDeferral) {
33743
- controller.commandDeferral(runCommand);
33824
+ const runCommand = resolveNaviCommand(element, command, e);
33825
+ if (runCommand) {
33826
+ controller.commandDeferral(runCommand);
33827
+ }
33744
33828
  } else {
33745
- runCommand();
33829
+ triggerNaviCommand(element, command, e);
33746
33830
  }
33747
33831
  }
33748
33832
  }
@@ -34399,7 +34483,12 @@ const GROUP_DEFAULTS = {
34399
34483
  }
34400
34484
  // Merged in on the way up (see above), so on the way down it takes the
34401
34485
  // whole object and picks out its own keys — the same value it produced.
34402
- if (isNamelessGrouping(child, child.uiState)) {
34486
+ // Only for a child with no name of its own: the same condition the
34487
+ // aggregate applies before merging. A NAMED child holding an object (a
34488
+ // `type="object"` picker, say) answers for one key, and the object it is
34489
+ // not mentioned in says nothing about it — handing it the whole value
34490
+ // would make it hold the group.
34491
+ if (!childName && isNamelessGrouping(child, child.uiState)) {
34403
34492
  return newUIState;
34404
34493
  }
34405
34494
  if (
@@ -34687,7 +34776,17 @@ const useUIGroupStateController = (
34687
34776
  e,
34688
34777
  `${controlType}.getUIState -> ${JSON.stringify(groupUIState)}`,
34689
34778
  );
34690
- if (notifyExternal === true) {
34779
+ if (notifyExternal === true || notifyExternal === "if-it-moves") {
34780
+ if (
34781
+ notifyExternal === "if-it-moves" &&
34782
+ compareTwoJsValues(groupUIState, controller.uiState)
34783
+ ) {
34784
+ // A child confirming what it already shows, to a group already
34785
+ // worth it: the value arrived while the popup was open and the
34786
+ // group reacted then. One trip through a picker is one answer, so
34787
+ // there is nothing left to say here.
34788
+ return;
34789
+ }
34691
34790
  // Somebody answered: what the group is worth is what its children say
34692
34791
  // between them, from here on.
34693
34792
  controller.stateGivenFromAbove = false;
@@ -35001,7 +35100,7 @@ const useUIGroupStateController = (
35001
35100
  onChildUIAction: (
35002
35101
  childUIStateController,
35003
35102
  e,
35004
- { stateChanged, silent },
35103
+ { stateChanged, silent, onlyIfGroupValueMoves },
35005
35104
  ) => {
35006
35105
  const delegatedTo = delegatedChildrenRef.current.get(
35007
35106
  childUIStateController,
@@ -35010,6 +35109,7 @@ const useUIGroupStateController = (
35010
35109
  delegatedTo.onChildUIAction(childUIStateController, e, {
35011
35110
  stateChanged,
35012
35111
  silent,
35112
+ onlyIfGroupValueMoves,
35013
35113
  });
35014
35114
  return;
35015
35115
  }
@@ -35024,7 +35124,11 @@ const useUIGroupStateController = (
35024
35124
  );
35025
35125
  if (stateChanged) {
35026
35126
  onChange(e, {
35027
- notifyExternal: silent ? "silent" : true,
35127
+ notifyExternal: silent
35128
+ ? "silent"
35129
+ : onlyIfGroupValueMoves
35130
+ ? "if-it-moves"
35131
+ : true,
35028
35132
  actingChild: childUIStateController,
35029
35133
  });
35030
35134
  } else {
@@ -36355,6 +36459,14 @@ const useControlProps = (props, {
36355
36459
  return false;
36356
36460
  }
36357
36461
  const control = ref.current;
36462
+ if (!control) {
36463
+ // The ui action the reaction ran a line earlier is free to take this
36464
+ // control away: a list row answering its popup's question closes it, and
36465
+ // mount="while-opened" unmounts the row. Nothing left to read a value
36466
+ // from, and what the press was for has already happened (the button and
36467
+ // link reactions bail on the same ground).
36468
+ return false;
36469
+ }
36358
36470
  const currentValue = readControlValue(control);
36359
36471
  // For checkables: skip value dedup. The browser only fires `input` when state
36360
36472
  // actually changes, so there is no spurious double-dispatch to guard against.
@@ -41904,7 +42016,7 @@ const NavStyleCSSVars = {
41904
42016
  paddingLeft: "--nav-padding-left",
41905
42017
  background: "--nav-background",
41906
42018
  currentIndicatorColor: "--nav-current-indicator-color",
41907
- currentIndicatorSize: "--nav-current-indicator-size"
42019
+ currentIndicatorSize: ["--nav-current-indicator-size", "height"]
41908
42020
  };
41909
42021
  const positionOfCurrentIndicator = (currentIndicator, vertical) => {
41910
42022
  if (currentIndicator === true) {
@@ -42999,7 +43111,7 @@ const css$V = /* css */`@layer navi {
42999
43111
  const BinderStyleCSSVars = {
43000
43112
  borderWidth: "--binder-border-width",
43001
43113
  borderRadius: "--binder-border-radius",
43002
- tabBorderRadius: "--binder-tab-border-radius",
43114
+ tabBorderRadius: ["--binder-tab-border-radius", "borderRadius"],
43003
43115
  borderColor: "--binder-border-color",
43004
43116
  background: "--binder-background",
43005
43117
  tabBackground: "--binder-tab-background",
@@ -43008,7 +43120,7 @@ const BinderStyleCSSVars = {
43008
43120
  tabColor: "--binder-tab-color",
43009
43121
  paddingX: "--binder-padding-x",
43010
43122
  paddingY: "--binder-padding-y",
43011
- pagePadding: "--binder-page-padding"
43123
+ pagePadding: ["--binder-page-padding", "padding"]
43012
43124
  };
43013
43125
  const TABS_ALIGN_TO_JUSTIFY_CONTENT = {
43014
43126
  start: "flex-start",
@@ -43159,12 +43271,12 @@ const Binder = ({
43159
43271
  "data-tabs-position": tabsPosition,
43160
43272
  "data-tabs-align": tabsAlign,
43161
43273
  "data-scrollable-page": scrollablePage ? "" : undefined,
43162
- borderWidth: withPixelUnit(borderWidth),
43163
- borderRadius: withPixelUnit(borderRadius),
43164
- tabBorderRadius: withPixelUnit(tabBorderRadius),
43165
- paddingX: withPixelUnit(paddingX),
43166
- paddingY: withPixelUnit(paddingY),
43167
- pagePadding: withPixelUnit(pagePadding),
43274
+ borderWidth: borderWidth,
43275
+ borderRadius: borderRadius,
43276
+ tabBorderRadius: tabBorderRadius,
43277
+ paddingX: paddingX,
43278
+ paddingY: paddingY,
43279
+ pagePadding: pagePadding,
43168
43280
  ...props,
43169
43281
  styleCSSVars: BinderStyleCSSVars,
43170
43282
  children: [jsx(BinderOutline, {
@@ -43397,15 +43509,6 @@ const measureDrawing = ({
43397
43509
  };
43398
43510
  };
43399
43511
 
43400
- // A bare number in a CSS var stays a bare number and the declaration is
43401
- // dropped; every length prop here is in pixels when it is not spelled out.
43402
- const withPixelUnit = value => {
43403
- if (typeof value === "number") {
43404
- return `${value}px`;
43405
- }
43406
- return value;
43407
- };
43408
-
43409
43512
  /**
43410
43513
  * How much room the fixed bars take on each edge, published for the safe area
43411
43514
  * to add up (see layout/safe_area.js — it declares the four variables written
@@ -48121,14 +48224,13 @@ const FieldAsLabel = props => {
48121
48224
  });
48122
48225
  };
48123
48226
  const FieldCSSVars = {
48124
- spacingWithControl: "--spacing-with-control"
48227
+ spacingWithControl: ["--spacing-with-control", "padding"]
48125
48228
  };
48126
48229
  const FieldAsContainer = props => {
48127
48230
  import.meta.css = [css$L, "@jsenv/navi/src/control/field.jsx"];
48128
48231
  const {
48129
48232
  children
48130
48233
  } = props;
48131
- props.spacingWithControl = resolveSpacingSize(props.spacingWithControl);
48132
48234
  const isVertical = props.flex === "y";
48133
48235
  const [messageProps, remainingProps] = extractMessageAndRemainingProps({
48134
48236
  ...props,
@@ -50242,10 +50344,6 @@ const useInputTextualProps = props => {
50242
50344
  };
50243
50345
  const InputTextualUI = props => {
50244
50346
  installInputCss();
50245
- // Spacing props travel to CSS as a raw custom property value, so the size
50246
- // keywords have to become lengths here — "s" reaching CSS untouched makes the
50247
- // declaration invalid, silently, and the gap just goes away.
50248
- props.slotSpacing = resolveSpacingSize(props.slotSpacing);
50249
50347
  const {
50250
50348
  ui,
50251
50349
  variant,
@@ -50450,7 +50548,7 @@ const RealInput = ({
50450
50548
  // Shared with textarea.jsx: a textarea is styled as a .navi_input box, so the
50451
50549
  // two read the same style props and pseudo states.
50452
50550
  const InputStyleCSSVars = {
50453
- "slotSpacing": "--slot-spacing",
50551
+ "slotSpacing": ["--slot-spacing", "margin"],
50454
50552
  "outlineWidth": "--outline-width",
50455
50553
  "borderWidth": "--border-width",
50456
50554
  "borderRadius": "--border-radius",
@@ -51088,6 +51186,10 @@ const css$I = /* css */`.navi_textarea {
51088
51186
  }
51089
51187
  }
51090
51188
 
51189
+ &[data-width-from-container] {
51190
+ contain: inline-size;
51191
+ }
51192
+
51091
51193
  &[data-resizable] {
51092
51194
  & .navi_control_input {
51093
51195
  height: calc(var(--textarea-min-rows, 1.5) * 1lh);
@@ -51138,9 +51240,13 @@ const TextareaStyleCSSVars = {
51138
51240
  * @param {number} [maxLength] The character limit, validated at submit. Pair
51139
51241
  * with `maxLengthGuard` to block typing past it, and render a
51140
51242
  * TextareaCharCount to show it.
51141
- * @param {string} [width="35ch"] The control's width. Fixed on purpose: with
51142
- * field-sizing the width would otherwise follow the longest line, and a box
51143
- * that widens while one types is a box one chases.
51243
+ * @param {string} [width="35ch"] The control's width. A length by default, on
51244
+ * purpose: with field-sizing the width would otherwise follow the longest
51245
+ * line, and a box that widens while one types is a box one chases. A
51246
+ * percentage means "fill what contains me" and holds the same promise — the
51247
+ * control then takes the width it is given without having a say in it, even
51248
+ * inside a container that sizes itself from its content (a Popover, a
51249
+ * Picker's popup), where the text wraps instead of widening the popup.
51144
51250
  */
51145
51251
  const Textarea = ({
51146
51252
  // Destructured, never deleted off the props object: Preact reuses the same
@@ -51167,8 +51273,21 @@ const Textarea = ({
51167
51273
  // element they would become its text content.
51168
51274
  delete hostProps.children;
51169
51275
  const loading = basePseudoState[":-navi-loading"];
51276
+
51277
+ // A percentage is not a definite length: while a container is being sized
51278
+ // from its content it cannot be resolved, so the box falls back to its own
51279
+ // max-content — which `field-sizing: content` makes the longest line typed,
51280
+ // and the field widens the very box it asked to fill. The CSS above takes
51281
+ // that vote away, which is why the percentage lands on the contained box
51282
+ // rather than on the field: the field fills it from the inside (flex-grow
51283
+ // in input_css.js).
51284
+ const widthFromContainer = String(width).includes("%");
51170
51285
  delete rootProps.width;
51171
- hostProps.width = width;
51286
+ if (widthFromContainer) {
51287
+ rootProps.width = width;
51288
+ } else {
51289
+ hostProps.width = width;
51290
+ }
51172
51291
  return jsxs(Box, {
51173
51292
  as: "span",
51174
51293
  inline: true,
@@ -51177,6 +51296,7 @@ const Textarea = ({
51177
51296
  ...rootProps,
51178
51297
  basePseudoState: basePseudoState,
51179
51298
  "data-resizable": resizable ? "" : undefined,
51299
+ "data-width-from-container": widthFromContainer ? "" : undefined,
51180
51300
  styleCSSVars: TextareaStyleCSSVars,
51181
51301
  pseudoStateSelector: ".navi_control_input",
51182
51302
  pseudoClasses: InputPseudoClasses,
@@ -51739,6 +51859,17 @@ const isUIStateHeld = (controller) => {
51739
51859
  * `command` on a control is its reaction to being used, and this is a
51740
51860
  * confirmation happening elsewhere, whose own command (the picker's) is already
51741
51861
  * running.
51862
+ *
51863
+ * And up one, to the group the control answers to: a picker inside a form is
51864
+ * one of its fields, and a form is worth what its fields say. What the popup
51865
+ * put there arrived through a mount sync, which is deliberately silent — a
51866
+ * popup opening is nobody answering (see onChange in ui_state_controller.js) —
51867
+ * so this is the first moment the form can be told.
51868
+ *
51869
+ * That group only hears about it when it moves: a value the user picked in the
51870
+ * popup reached it already, and one trip through a picker is one answer. Down
51871
+ * the subtree the reaction re-runs either way — a control saying again what it
51872
+ * holds says the same thing, and it is where a signal is written.
51742
51873
  */
51743
51874
  const commitUIStateAsAnswer = (controller, e) => {
51744
51875
  if (!controller) {
@@ -51746,6 +51877,10 @@ const commitUIStateAsAnswer = (controller, e) => {
51746
51877
  }
51747
51878
  const answering = controller.facadeChild || controller;
51748
51879
  commitSubtree(answering, e);
51880
+ controller.parentUIStateController?.onChildUIAction(controller, e, {
51881
+ stateChanged: true,
51882
+ onlyIfGroupValueMoves: true,
51883
+ });
51749
51884
  };
51750
51885
  const commitSubtree = (controller, e) => {
51751
51886
  controller.onUIAction?.(e, { skipCommand: true });
@@ -68801,16 +68936,6 @@ const PickerButton = props => {
68801
68936
  if (typeof props.maxLines === "string") {
68802
68937
  props.maxLines = parseInt(props.maxLines);
68803
68938
  }
68804
- // Spacing props travel to CSS as a raw custom property value, so the size
68805
- // keywords have to become lengths here — "s" reaching CSS untouched makes
68806
- // the declaration invalid, silently, and the gap just goes away.
68807
- props.slotSpacing = resolveSpacingSize(props.slotSpacing);
68808
- for (const pressPaddingPropName of PRESS_PADDING_PROP_NAMES) {
68809
- const pressPadding = props[pressPaddingPropName];
68810
- if (pressPadding !== undefined) {
68811
- props[pressPaddingPropName] = stringifySpacingStyle(pressPadding);
68812
- }
68813
- }
68814
68939
  const {
68815
68940
  ref,
68816
68941
  variant,
@@ -69408,32 +69533,29 @@ const NON_MOBILE_KEYBOARD_TYPES = new Set(["date", "month", "week", "time", "dat
69408
69533
  const PICKER_BUTTON_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-loading", ":-navi-expanded", ":-navi-has-value"];
69409
69534
  const PickerInputPseudoClasses = [":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading", ":-navi-has-value", ":-navi-expanded"];
69410
69535
 
69411
- // Spacing props that are not the names of real CSS styles, so Box hands them to
69412
- // the custom property untouched: a size keyword would reach CSS as the word "s"
69413
- // and a number as a unitless one, either of which makes the calc() using it
69414
- // invalid and drops the press area back to the box. Hence the pass in
69415
- // PickerButton, which turns them into lengths.
69416
- const PRESS_PADDING_PROP_NAMES = ["pressPadding", "pressPaddingX", "pressPaddingY", "pressPaddingTop", "pressPaddingRight", "pressPaddingBottom", "pressPaddingLeft"];
69536
+ // A pair is [the css variable, the style whose values the prop is written in]:
69537
+ // popupBorderRadius takes what borderRadius takes, dialogMinWidth what minWidth
69538
+ // takes — see box.jsx's readCSSVarEntry.
69417
69539
  const PickerStyleCSSVars = {
69418
69540
  "outlineWidth": "--picker-outline-width",
69419
69541
  "borderWidth": "--picker-border-width",
69420
69542
  "borderRadius": "--picker-border-radius",
69421
- "popoverMaxHeight": "--picker-popover-max-height",
69422
- "dialogMinWidth": "--picker-dialog-min-width",
69423
- "dialogMinHeight": "--picker-dialog-min-height",
69424
- "dialogMaxWidth": "--picker-dialog-max-width",
69425
- "dialogMaxHeight": "--picker-dialog-max-height",
69543
+ "popoverMaxHeight": ["--picker-popover-max-height", "maxHeight"],
69544
+ "dialogMinWidth": ["--picker-dialog-min-width", "minWidth"],
69545
+ "dialogMinHeight": ["--picker-dialog-min-height", "minHeight"],
69546
+ "dialogMaxWidth": ["--picker-dialog-max-width", "maxWidth"],
69547
+ "dialogMaxHeight": ["--picker-dialog-max-height", "maxHeight"],
69426
69548
  "popupBackgroundColor": "--picker-popup-background-color",
69427
- "popupBorderRadius": "--picker-popup-border-radius",
69428
- "dialogBorderWidth": "--picker-dialog-border-width",
69429
- "slotSpacing": "--picker-slot-spacing",
69430
- "pressPadding": "--picker-press-padding",
69431
- "pressPaddingX": "--picker-press-padding-x",
69432
- "pressPaddingY": "--picker-press-padding-y",
69433
- "pressPaddingTop": "--picker-press-padding-top",
69434
- "pressPaddingRight": "--picker-press-padding-right",
69435
- "pressPaddingBottom": "--picker-press-padding-bottom",
69436
- "pressPaddingLeft": "--picker-press-padding-left",
69549
+ "popupBorderRadius": ["--picker-popup-border-radius", "borderRadius"],
69550
+ "dialogBorderWidth": ["--picker-dialog-border-width", "borderWidth"],
69551
+ "slotSpacing": ["--picker-slot-spacing", "margin"],
69552
+ "pressPadding": ["--picker-press-padding", "padding"],
69553
+ "pressPaddingX": ["--picker-press-padding-x", "padding"],
69554
+ "pressPaddingY": ["--picker-press-padding-y", "padding"],
69555
+ "pressPaddingTop": ["--picker-press-padding-top", "padding"],
69556
+ "pressPaddingRight": ["--picker-press-padding-right", "padding"],
69557
+ "pressPaddingBottom": ["--picker-press-padding-bottom", "padding"],
69558
+ "pressPaddingLeft": ["--picker-press-padding-left", "padding"],
69437
69559
  // alignX/alignY resolve to these two on a flex-x box; naming the CSS style
69438
69560
  // (not the prop) is what styleCSSVars matches, so justifyContent/alignItems
69439
69561
  // passed directly land in the same variables.