@jsenv/navi 0.29.15 → 0.29.17

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,11 +3,11 @@
3
3
  * using @jsenv/navi as intended.
4
4
  */
5
5
  import { windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, installImportMetaCssBuild, coarsePointerSignal } from "./jsenv_navi_side_effects.js";
6
- import { createContext, isValidElement, h, Fragment, toChildArray, render, cloneElement } from "preact";
6
+ import { createContext, isValidElement, h, Fragment, toChildArray, render, options, cloneElement } from "preact";
7
7
  import { useContext, useLayoutEffect, useRef, useEffect, useCallback, useState, useMemo, useId, useErrorBoundary } from "preact/hooks";
8
8
  import { jsx, jsxs, Fragment as Fragment$1 } from "preact/jsx-runtime";
9
9
  import { computed, signal, effect, batch, useSignal } from "@preact/signals";
10
- import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, dispatchCustomEvent, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, dragAfterThreshold, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
10
+ import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, dispatchInternalCustomEvent, dispatchCustomEvent, findEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, dragAfterThreshold, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
11
11
  export { contrastColor, startDragToReorder } from "@jsenv/dom";
12
12
  import { createValidity, parseDuration, durationContainsNaN, compareTwoDurations, durationToSeconds, durationToISOString } from "@jsenv/validity";
13
13
  export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
@@ -6490,6 +6490,10 @@ const POSITION_PROPS = {
6490
6490
  fixed: applyToCssPropWhenTruthy("position", "fixed", "static"),
6491
6491
  sticky: applyToCssPropWhenTruthy("position", "sticky", "static"),
6492
6492
  zIndex: PASS_THROUGH,
6493
+ // Keeps the zIndex values used inside this box local to it — see
6494
+ // docs/z_index.md: a z-index that opens no stacking context competes with
6495
+ // the whole page, fixed bars included.
6496
+ isolation: PASS_THROUGH,
6493
6497
  order: PASS_THROUGH,
6494
6498
  left: (value) => {
6495
6499
  return { left: value === true ? 0 : value };
@@ -7263,6 +7267,367 @@ const findControlRoot = (el) => {
7263
7267
  return null;
7264
7268
  };
7265
7269
 
7270
+ const dispatchRequestSetUIState = (element, value, detail) => {
7271
+ const controlHost = findControlHost(element) || element;
7272
+ return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
7273
+ ...detail,
7274
+ value,
7275
+ });
7276
+ };
7277
+ const dispatchRequestClearUIState = (element, e) => {
7278
+ const controlHost = findControlHost(element) || element;
7279
+ return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
7280
+ event: e,
7281
+ });
7282
+ };
7283
+ const dispatchRequestResetUIState = (element, e) => {
7284
+ const controlHost = findControlHost(element) || element;
7285
+ return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
7286
+ event: e,
7287
+ });
7288
+ };
7289
+ /**
7290
+ * @param {Element} el
7291
+ * @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
7292
+ * Only a button ever answers differently — one with no value of its own
7293
+ * inherits the value of the control around it, which is what makes
7294
+ * `--navi-send` on a form's button be about that form. Something asking what
7295
+ * THIS element says (a travel command reading what the travel is about) wants
7296
+ * the own value and would otherwise be handed the surrounding control's.
7297
+ */
7298
+ const getUIStateFromElement = (el, { own } = {}) => {
7299
+ let uiState;
7300
+ dispatchInternalCustomEvent(el, "navi_get_ui_state", {
7301
+ own,
7302
+ respondWith: (v) => {
7303
+ uiState = v;
7304
+ },
7305
+ });
7306
+ return uiState;
7307
+ };
7308
+
7309
+ /**
7310
+ * Converts a JS value into the form expected by the browser DOM property for a
7311
+ * given control type/input type combination.
7312
+ *
7313
+ * For example:
7314
+ * - `datetime-local` inputs expect a local datetime string without timezone
7315
+ * - `number`/`range` inputs expect a numeric string or number
7316
+ * - `color` inputs require a non-empty hex string (falls back to `#000000`)
7317
+ * - All other inputs receive the value as-is (undefined → "")
7318
+ *
7319
+ * Returns either the converted value directly, or a converter function when the
7320
+ * conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
7321
+ *
7322
+ * @param {any} value - The JS value to convert.
7323
+ * @param {{ controlType: string, type: string }} options
7324
+ * @returns {any} The DOM-compatible value or a converter function.
7325
+ */
7326
+ const asControlHostValue = (
7327
+ jsValue,
7328
+ { controlType, type, inputMode },
7329
+ ) => {
7330
+ if (controlType === "select") {
7331
+ // A select holds one of its options, always a string; holding nothing is
7332
+ // the empty option, which the element spells "".
7333
+ return asInputValue(jsValue);
7334
+ }
7335
+ if (controlType === "input" || controlType === "picker") {
7336
+ if (type === "datetime-local") {
7337
+ return asDatetimeLocalString(jsValue);
7338
+ }
7339
+ if (
7340
+ type === "number" ||
7341
+ type === "range" ||
7342
+ inputMode === "numeric" ||
7343
+ inputMode === "decimal"
7344
+ ) {
7345
+ return asNumberString(jsValue);
7346
+ }
7347
+ if (type === "color") {
7348
+ return asColorString(jsValue);
7349
+ }
7350
+ return asInputValue(jsValue);
7351
+ }
7352
+ return jsValue;
7353
+ };
7354
+ // As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
7355
+ // datetime-local does not support timezones
7356
+ const asDatetimeLocalString = (dateTimeString) => {
7357
+ const date = new Date(dateTimeString);
7358
+ if (isNaN(date.getTime())) {
7359
+ return dateTimeString;
7360
+ }
7361
+ const year = date.getFullYear();
7362
+ const month = String(date.getMonth() + 1).padStart(2, "0");
7363
+ const day = String(date.getDate()).padStart(2, "0");
7364
+ const hours = String(date.getHours()).padStart(2, "0");
7365
+ const minutes = String(date.getMinutes()).padStart(2, "0");
7366
+ const seconds = String(date.getSeconds()).padStart(2, "0");
7367
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
7368
+ };
7369
+ const asNumberString = (jsValue) => {
7370
+ if (jsValue === undefined) {
7371
+ return "";
7372
+ }
7373
+ return jsValue;
7374
+ };
7375
+ // Browser requires a non-empty value for <input type="color">.
7376
+ // When our logical value is empty we give it #000000 so it doesn't choke.
7377
+ // The UI uses the original (possibly empty) value to show the checkerboard.
7378
+ const asColorString = (jsValue) => {
7379
+ return jsValue || "#000000";
7380
+ };
7381
+ const asInputValue = (jsValue) => {
7382
+ if (jsValue === undefined) {
7383
+ return "";
7384
+ }
7385
+ return jsValue;
7386
+ };
7387
+
7388
+ /**
7389
+ * Reads the current logical JS value from a control host DOM element.
7390
+ *
7391
+ * Handles all navi control host element types:
7392
+ * - `<button>` — reads via `navi_get_value` custom event, falls back to `button.value`
7393
+ * - `<input type="number|range">` — parses as a number, returns `undefined` when empty
7394
+ * - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
7395
+ * via `navi_get_value` custom event (to preserve the original JS type of the value prop)
7396
+ * - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
7397
+ * - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
7398
+ * - All other inputs — returns `input.value` as a string
7399
+ *
7400
+ * @param {HTMLElement} controlHost - The control host DOM element to read from.
7401
+ * @returns {any} The current logical value of the control.
7402
+ */
7403
+ const readControlValue = (controlHost) => {
7404
+ if (
7405
+ controlHost.tagName === "BUTTON" ||
7406
+ controlHost.getAttribute("role") === "button"
7407
+ ) {
7408
+ return readValueFromButton(controlHost);
7409
+ }
7410
+ if (controlHost.tagName === "INPUT") {
7411
+ // important: input.type = "navi_js"; followed by input.type; returns "text"
7412
+ // so use getAttribute
7413
+ const type = controlHost.getAttribute("type");
7414
+
7415
+ if (
7416
+ type === "number" ||
7417
+ type === "range" ||
7418
+ controlHost.inputMode === "numeric" ||
7419
+ controlHost.inputMode === "decimal"
7420
+ ) {
7421
+ return readNumberFromInput(controlHost);
7422
+ }
7423
+ if (type === "color") {
7424
+ return readValueFromControlHost(controlHost);
7425
+ }
7426
+ if (type === "checkbox" || type === "radio") {
7427
+ return readValueFromCheckableInput(controlHost);
7428
+ }
7429
+ if (type === "datetime-local") {
7430
+ return readDatetimeLocalFromInput(controlHost);
7431
+ }
7432
+ if (type === "navi_js") {
7433
+ return getUIStateFromElement(controlHost);
7434
+ }
7435
+ return readValueFromInput(controlHost);
7436
+ }
7437
+ if (controlHost.hasAttribute("navi-control-host")) {
7438
+ // Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
7439
+ return readValueFromControlHost(controlHost);
7440
+ }
7441
+ return readValueFromElement(controlHost);
7442
+ };
7443
+ const readValueFromControlHost = (controlHost) => {
7444
+ return readValueFromNaviCustomEvent(controlHost, controlHost.value);
7445
+ };
7446
+ const readValueFromButton = (button) => {
7447
+ return readValueFromControlHost(button);
7448
+ };
7449
+ const readDatetimeLocalFromInput = (input) => {
7450
+ const localDateTimeString = input.value;
7451
+ if (localDateTimeString === "") {
7452
+ return "";
7453
+ }
7454
+ const localDate = new Date(localDateTimeString);
7455
+ if (isNaN(localDate.getTime())) {
7456
+ return localDateTimeString;
7457
+ }
7458
+ return localDate.toISOString();
7459
+ };
7460
+ const readNumberFromInput = (input) => {
7461
+ const numberString = input.value;
7462
+ if (numberString === "") {
7463
+ return "";
7464
+ }
7465
+ const asNumber = Number(numberString);
7466
+ if (isNaN(asNumber)) {
7467
+ return numberString;
7468
+ }
7469
+ return asNumber;
7470
+ };
7471
+ const readValueFromCheckableInput = (input) => {
7472
+ const checked = input.checked;
7473
+ if (!checked) {
7474
+ return undefined;
7475
+ }
7476
+ return readValueFromControlHost(input);
7477
+ };
7478
+ const readValueFromInput = (input) => {
7479
+ const value = input.value;
7480
+ return value;
7481
+ };
7482
+ const readValueFromElement = (element) => {
7483
+ const value = element.value;
7484
+ return value;
7485
+ };
7486
+ const readValueFromNaviCustomEvent = (field, fallback) => {
7487
+ // prefer the value given as prop (respect original type, browser would convert to string)
7488
+ let responded;
7489
+ let value;
7490
+ dispatchCustomEvent(field, "navi_get_value", {
7491
+ respondWith: (jsValue) => {
7492
+ responded = true;
7493
+ value = jsValue;
7494
+ },
7495
+ });
7496
+ if (responded) {
7497
+ return value;
7498
+ }
7499
+ return fallback;
7500
+ };
7501
+
7502
+ // In-memory registry of all mounted ui state controllers keyed by their id.
7503
+ // Allows direct controller access without dispatching DOM events — used by external
7504
+ // callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
7505
+ const controllersById = new Map();
7506
+
7507
+ // In-memory registry for radio controllers, keyed by input name.
7508
+ // Allows radio sibling unchecking without querying the DOM — necessary when
7509
+ // items are virtualized and their DOM element may not exist at the time.
7510
+ // Form scoping is reproduced by comparing parentUIStateController references.
7511
+ const radioControllersByName = new Map();
7512
+
7513
+ // Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
7514
+ // When a value is an object/array, we store it here and write a reference string to the DOM
7515
+ // instead of "[object Object]". Console-inspectable via window.__navi_js('id').
7516
+ // The controller id is used as key — if the controller has no id, the value is not registered.
7517
+ const naviJsRegistry = new Map();
7518
+
7519
+ const getUIStateControllerById = (id) => controllersById.get(id);
7520
+ const getRadioSiblings = (radioUIStateController) => {
7521
+ const siblings = radioControllersByName.get(radioUIStateController.name);
7522
+ return siblings;
7523
+ };
7524
+
7525
+ const toDomValue = (jsValue, { controlType, id, type, inputMode }) => {
7526
+ const domValue = asControlHostValue(jsValue, {
7527
+ controlType,
7528
+ type,
7529
+ inputMode,
7530
+ });
7531
+ if (isSerializableAsDomValue(domValue)) {
7532
+ return domValue;
7533
+ }
7534
+ naviJsRegistry.set(id, domValue);
7535
+ return `window.__navi_js('${id}')`;
7536
+ };
7537
+
7538
+ window.__navi_js = (id) => naviJsRegistry.get(id);
7539
+ const isSerializableAsDomValue = (value) => {
7540
+ if (value === null || value === undefined) {
7541
+ return true;
7542
+ }
7543
+ const type = typeof value;
7544
+ return type === "string" || type === "number" || type === "boolean";
7545
+ };
7546
+
7547
+ const onUIStateControllerCreated = (uiStateController) => {
7548
+ const { id, name, controlType } = uiStateController;
7549
+ if (id) {
7550
+ controllersById.set(id, uiStateController);
7551
+ }
7552
+ const proxyFor = uiStateController.props["navi-control-proxy-for"];
7553
+ if (proxyFor) {
7554
+ let proxySet = proxyControllersByRealInputId.get(proxyFor);
7555
+ if (!proxySet) {
7556
+ proxySet = new Set();
7557
+ proxyControllersByRealInputId.set(proxyFor, proxySet);
7558
+ }
7559
+ proxySet.add(uiStateController);
7560
+ }
7561
+ if (
7562
+ controlType === "input" &&
7563
+ uiStateController.props.type === "radio" &&
7564
+ name
7565
+ ) {
7566
+ let set = radioControllersByName.get(name);
7567
+ if (!set) {
7568
+ set = new Set();
7569
+ radioControllersByName.set(name, set);
7570
+ }
7571
+ set.add(uiStateController);
7572
+ }
7573
+ };
7574
+ const onUIStateControllerDestroyed = (uiStateController) => {
7575
+ const { id, name, controlType } = uiStateController;
7576
+ if (id) {
7577
+ controllersById.delete(id);
7578
+ naviJsRegistry.delete(id);
7579
+ }
7580
+ const proxyFor = uiStateController.props["navi-control-proxy-for"];
7581
+ if (proxyFor) {
7582
+ const proxySet = proxyControllersByRealInputId.get(proxyFor);
7583
+ if (proxySet) {
7584
+ proxySet.delete(uiStateController);
7585
+ if (proxySet.size === 0) {
7586
+ proxyControllersByRealInputId.delete(proxyFor);
7587
+ }
7588
+ }
7589
+ }
7590
+ if (
7591
+ controlType === "input" &&
7592
+ uiStateController.controlHostProps.type === "radio" &&
7593
+ name
7594
+ ) {
7595
+ const set = radioControllersByName.get(name);
7596
+ if (set) {
7597
+ set.delete(uiStateController);
7598
+ if (set.size === 0) {
7599
+ radioControllersByName.delete(name);
7600
+ }
7601
+ }
7602
+ }
7603
+ };
7604
+
7605
+ /**
7606
+ * Controller-based equivalent of findControlProxyTarget.
7607
+ * Given a proxy controller, returns the real control's controller.
7608
+ * Finds the target by walking the parent controller's children — no DOM queries.
7609
+ * Returns `null` when the controller is not a proxy or the target is not found.
7610
+ */
7611
+ const findControlProxyTargetController = (controller) => {
7612
+ const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
7613
+ if (!proxyFor) {
7614
+ return null;
7615
+ }
7616
+ return getUIStateControllerById(proxyFor) ?? null;
7617
+ };
7618
+
7619
+ // Reverse-lookup map: real-input id → the proxy controllers that reference it
7620
+ // via `navi-control-proxy-for`. A single control can be represented by several
7621
+ // proxies (an "enable"/"disable" button pair for one radio, for instance), so
7622
+ // each id holds a set. Maintained on create/destroy so lookup is O(1).
7623
+ const proxyControllersByRealInputId = new Map();
7624
+ const findProxyControllers = (realInputId) => {
7625
+ if (!realInputId) {
7626
+ return null;
7627
+ }
7628
+ return proxyControllersByRealInputId.get(realInputId) ?? null;
7629
+ };
7630
+
7266
7631
  /**
7267
7632
  * DOM utilities for the proxy control pattern.
7268
7633
  *
@@ -7294,6 +7659,7 @@ const findControlRoot = (el) => {
7294
7659
  * entirely. For now we keep the proxy pattern.
7295
7660
  */
7296
7661
 
7662
+
7297
7663
  /**
7298
7664
  * Given a proxy element, returns the real control it represents.
7299
7665
  * Returns `null` when `el` is not a proxy.
@@ -7306,22 +7672,49 @@ const findControlProxyTarget = (el) => {
7306
7672
  return document.getElementById(proxyFor);
7307
7673
  };
7308
7674
 
7675
+ /**
7676
+ * Given a real control element, returns every proxy that visually represents
7677
+ * it — a control can have more than one (an "enable"/"disable" button pair for
7678
+ * one radio, for instance).
7679
+ *
7680
+ * Answered from the controller registry rather than the document: every proxy
7681
+ * declares itself through the `navi-control-proxy-for` prop, so the registry
7682
+ * knows them all, while asking the document means walking it in full for each
7683
+ * of the (overwhelmingly many) controls that have no proxy at all.
7684
+ *
7685
+ * Returns an empty array when no proxy exists for `el`.
7686
+ */
7687
+ const findControlProxies = (el) => {
7688
+ if (!el.id) {
7689
+ return [];
7690
+ }
7691
+ const proxyControllerSet = findProxyControllers(el.id);
7692
+ if (!proxyControllerSet) {
7693
+ return [];
7694
+ }
7695
+ const proxyElements = [];
7696
+ for (const proxyController of proxyControllerSet) {
7697
+ const proxyElement = proxyController.ref.current;
7698
+ if (proxyElement) {
7699
+ proxyElements.push(proxyElement);
7700
+ }
7701
+ }
7702
+ return proxyElements;
7703
+ };
7704
+
7309
7705
  /**
7310
7706
  * Given a real control element, returns the proxy that visually represents it.
7311
7707
  *
7312
- * Use when you need to update or recheck the proxy's visual state after the
7313
- * real control's state changes, or when anchoring a callout to the visible
7314
- * element rather than the hidden real input.
7708
+ * Use when you need a single visible stand-in for the real control anchoring
7709
+ * a callout, for instance. Anything notifying proxies of a state change wants
7710
+ * `findControlProxies` instead, so a control represented by several of them
7711
+ * updates all of them.
7315
7712
  *
7316
7713
  * Returns `null` when no proxy exists for `el`.
7317
7714
  */
7318
7715
  const findControlProxy = (el) => {
7319
- if (!el.id) {
7320
- return null;
7321
- }
7322
- return document.querySelector(
7323
- `[navi-control-proxy-for="${CSS.escape(el.id)}"]`,
7324
- );
7716
+ const [firstProxy = null] = findControlProxies(el);
7717
+ return firstProxy;
7325
7718
  };
7326
7719
 
7327
7720
  const addInputEffect = (
@@ -7580,56 +7973,16 @@ const listenInputStateChange = (
7580
7973
  return teardown;
7581
7974
  };
7582
7975
 
7583
- const dispatchRequestSetUIState = (element, value, detail) => {
7584
- const controlHost = findControlHost(element) || element;
7585
- return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
7586
- ...detail,
7587
- value,
7588
- });
7589
- };
7590
- const dispatchRequestClearUIState = (element, e) => {
7591
- const controlHost = findControlHost(element) || element;
7592
- return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
7593
- event: e,
7594
- });
7595
- };
7596
- const dispatchRequestResetUIState = (element, e) => {
7597
- const controlHost = findControlHost(element) || element;
7598
- return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
7599
- event: e,
7600
- });
7601
- };
7602
- /**
7603
- * @param {Element} el
7604
- * @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
7605
- * Only a button ever answers differently — one with no value of its own
7606
- * inherits the value of the control around it, which is what makes
7607
- * `--navi-send` on a form's button be about that form. Something asking what
7608
- * THIS element says (a travel command reading what the travel is about) wants
7609
- * the own value and would otherwise be handed the surrounding control's.
7610
- */
7611
- const getUIStateFromElement = (el, { own } = {}) => {
7612
- let uiState;
7613
- dispatchInternalCustomEvent(el, "navi_get_ui_state", {
7614
- own,
7615
- respondWith: (v) => {
7616
- uiState = v;
7617
- },
7618
- });
7619
- return uiState;
7620
- };
7621
-
7622
7976
  const requestPseudoStateCheck = (element, detail) => {
7623
7977
  dispatchInternalCustomEvent(
7624
7978
  element,
7625
7979
  "navi_pseudo_state_request_check",
7626
7980
  detail,
7627
7981
  );
7628
- // When a control has a visible proxy mirroring its state (e.g. selectable
7629
- // radio with `navi-control-proxy-for`), re-check the proxy too so it stays
7630
- // in sync with the real control.
7631
- const proxy = findControlProxy(element);
7632
- if (proxy) {
7982
+ // When a control has visible proxies mirroring its state (e.g. selectable
7983
+ // radio with `navi-control-proxy-for`), re-check them too so they stay in
7984
+ // sync with the real control.
7985
+ for (const proxy of findControlProxies(element)) {
7633
7986
  dispatchInternalCustomEvent(
7634
7987
  proxy,
7635
7988
  "navi_pseudo_state_request_check",
@@ -7682,8 +8035,7 @@ definePseudoClass(":hover", {
7682
8035
  return () => {};
7683
8036
  }
7684
8037
  const recheckProxy = (e) => {
7685
- const proxy = findControlProxy(el);
7686
- if (proxy) {
8038
+ for (const proxy of findControlProxies(el)) {
7687
8039
  requestPseudoStateCheck(proxy, { event: e });
7688
8040
  }
7689
8041
  };
@@ -7750,9 +8102,10 @@ definePseudoClass(":hover", {
7750
8102
  if (el.matches(":hover")) {
7751
8103
  return true;
7752
8104
  }
7753
- const proxy = findControlProxy(el);
7754
- if (proxy && proxy.matches(":hover")) {
7755
- return true;
8105
+ for (const proxy of findControlProxies(el)) {
8106
+ if (proxy.matches(":hover")) {
8107
+ return true;
8108
+ }
7756
8109
  }
7757
8110
  return false;
7758
8111
  },
@@ -8114,23 +8467,30 @@ const isKeyboardModality = () => keyboardNavigationUsed;
8114
8467
  requireFocusVisible
8115
8468
  ? isMatchingFocusVisible(target)
8116
8469
  : target.matches(":focus");
8470
+ // Both branches of isFocusedTarget rest on :focus / :focus-visible, and only
8471
+ // one element in the document can match those: document.activeElement. So
8472
+ // the single controller worth testing is known upfront — asking the document
8473
+ // for every [aria-controls] would collect candidates that cannot qualify,
8474
+ // once per element and again on every re-check, on a document each new
8475
+ // element makes bigger.
8117
8476
  const isControlledBy = (target) => {
8118
8477
  const id = target.id;
8119
8478
  if (!id) {
8120
8479
  return false;
8121
8480
  }
8122
- const controllers = document.querySelectorAll(`[aria-controls~="${id}"]`);
8123
- for (const controller of controllers) {
8124
- // If the controller is inside the element it controls, focus is already
8125
- // native (:focus-within) — no need to inherit it.
8126
- if (target.contains(controller)) {
8127
- continue;
8128
- }
8129
- if (isFocusedTarget(controller)) {
8130
- return true;
8131
- }
8481
+ const activeElement = document.activeElement;
8482
+ if (!activeElement || activeElement === document.body) {
8483
+ return false;
8132
8484
  }
8133
- return false;
8485
+ if (!activeElement.matches(`[aria-controls~="${id}"]`)) {
8486
+ return false;
8487
+ }
8488
+ // A controller inside the element it controls means focus is already
8489
+ // native (:focus-within) — nothing to inherit.
8490
+ if (target.contains(activeElement)) {
8491
+ return false;
8492
+ }
8493
+ return isFocusedTarget(activeElement);
8134
8494
  };
8135
8495
  if (isControlledBy(el)) {
8136
8496
  return true;
@@ -8457,8 +8817,7 @@ const initPseudoStyles = (
8457
8817
  }
8458
8818
  // When this element's state changes, notify any proxy element that mirrors it
8459
8819
  // so it can re-check and visually reflect the new state.
8460
- const proxy = findControlProxy(element);
8461
- if (proxy) {
8820
+ for (const proxy of findControlProxies(element)) {
8462
8821
  requestPseudoStateCheck(proxy, {});
8463
8822
  }
8464
8823
  };
@@ -8905,6 +9264,20 @@ import.meta.css = [/* css */`
8905
9264
  between them — and a control flush against the edge of a scrolling area
8906
9265
  overflows it (a focus outline is drawn outside the control it belongs to)
8907
9266
  and raises a scrollbar of its own. */
9267
+ /* A control sitting right against the edge of what scrolls must keep its
9268
+ loading outline within its own box: the outline is drawn a couple pixels
9269
+ outside the control (see loading_outline.jsx), and that bleed alone is
9270
+ enough to make the area scrollable — a scrollbar appearing and disappearing
9271
+ as things load. Only what the scroller directly contains is against that
9272
+ edge; anything nested deeper has room around it and keeps the outline it
9273
+ asked for, hence the child combinators. Written on the outline itself
9274
+ rather than on the control, because the var inherits: setting it on a
9275
+ container would reach every control below it, edge or not. */
9276
+ [data-scrollable] > .navi_loading_outline_wrapper,
9277
+ [data-scrollable] > * > .navi_loading_outline_wrapper {
9278
+ --loading-outline-min-inset: 0px;
9279
+ }
9280
+
8908
9281
  [data-scrollable] {
8909
9282
  overflow: var(--x-scrollable-overflow, auto);
8910
9283
 
@@ -9742,7 +10115,7 @@ const setupNetworkMonitoring = () => {
9742
10115
  };
9743
10116
  setupNetworkMonitoring();
9744
10117
 
9745
- installImportMetaCssBuild(import.meta);const css$Y = /* css */`
10118
+ installImportMetaCssBuild(import.meta);const css$Z = /* css */`
9746
10119
  .navi_loading_indicator_fluid_container {
9747
10120
  position: relative;
9748
10121
  display: flex;
@@ -9774,7 +10147,7 @@ const LoadingIndicatorFluid = ({
9774
10147
  visuallyHidden,
9775
10148
  ...rest
9776
10149
  }) => {
9777
- import.meta.css = [css$Y, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
10150
+ import.meta.css = [css$Z, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
9778
10151
  const ref = useRef(null);
9779
10152
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
9780
10153
  const [containerWidth, setContainerWidth] = useState(0);
@@ -9979,7 +10352,7 @@ const LoadingRectangleSvg = ({
9979
10352
  });
9980
10353
  };
9981
10354
 
9982
- installImportMetaCssBuild(import.meta);const css$X = /* css */`
10355
+ installImportMetaCssBuild(import.meta);const css$Y = /* css */`
9983
10356
  .navi_loading_outline_wrapper {
9984
10357
  position: absolute;
9985
10358
  /* Controls place the outline slightly outside their box, right on top of
@@ -10016,7 +10389,7 @@ installImportMetaCssBuild(import.meta);const css$X = /* css */`
10016
10389
  }
10017
10390
  `;
10018
10391
  const LoadingOutline = props => {
10019
- import.meta.css = [css$X, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
10392
+ import.meta.css = [css$Y, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
10020
10393
  if (props.containerRef) {
10021
10394
  const container = props.containerRef.current;
10022
10395
  if (!container) {
@@ -10057,6 +10430,15 @@ const LoadingOutlineUI = props => {
10057
10430
  } = props;
10058
10431
  const shouldShowSpinner = useDebounceTrue(loading, debounce);
10059
10432
  const rectangleRef = useRef(null);
10433
+
10434
+ // Nothing in the DOM until something actually loads: the box below is
10435
+ // absolutely positioned slightly outside the control, which is enough to
10436
+ // make an ancestor scrollable (a 1px scrollbar on a control sitting against
10437
+ // the edge of a scrolling area). A control that never loads must not pay for
10438
+ // a decoration it will never draw.
10439
+ if (!loading) {
10440
+ return children;
10441
+ }
10060
10442
  let insetTop = inset + spacingTop + marginTop;
10061
10443
  let insetRight = inset + spacingRight + marginRight;
10062
10444
  let insetBottom = inset + spacingBottom + marginBottom;
@@ -10087,7 +10469,7 @@ const LoadingOutlineUI = props => {
10087
10469
  "--loading-rectangle-bottom": `${insetBottom}px`,
10088
10470
  "--loading-rectangle-left": `${insetLeft}px`
10089
10471
  },
10090
- children: loading && jsx(LoadingIndicatorFluid, {
10472
+ children: jsx(LoadingIndicatorFluid, {
10091
10473
  visuallyHidden: !shouldShowSpinner,
10092
10474
  radius: radius,
10093
10475
  color: color,
@@ -10341,7 +10723,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
10341
10723
  };
10342
10724
 
10343
10725
  installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
10344
- const css$W = /* css */`
10726
+ const css$X = /* css */`
10345
10727
  @layer navi {
10346
10728
  .navi_text {
10347
10729
  &[data-skeleton] {
@@ -10847,7 +11229,7 @@ const TextShrinkWrap = props => {
10847
11229
  });
10848
11230
  };
10849
11231
  const TextUI = props => {
10850
- import.meta.css = [css$W, "@jsenv/navi/src/text/text.jsx"];
11232
+ import.meta.css = [css$X, "@jsenv/navi/src/text/text.jsx"];
10851
11233
  let {
10852
11234
  ref,
10853
11235
  spacing,
@@ -13164,310 +13546,6 @@ const useActionStatus = (action) => {
13164
13546
  };
13165
13547
  };
13166
13548
 
13167
- /**
13168
- * Converts a JS value into the form expected by the browser DOM property for a
13169
- * given control type/input type combination.
13170
- *
13171
- * For example:
13172
- * - `datetime-local` inputs expect a local datetime string without timezone
13173
- * - `number`/`range` inputs expect a numeric string or number
13174
- * - `color` inputs require a non-empty hex string (falls back to `#000000`)
13175
- * - All other inputs receive the value as-is (undefined → "")
13176
- *
13177
- * Returns either the converted value directly, or a converter function when the
13178
- * conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
13179
- *
13180
- * @param {any} value - The JS value to convert.
13181
- * @param {{ controlType: string, type: string }} options
13182
- * @returns {any} The DOM-compatible value or a converter function.
13183
- */
13184
- const asControlHostValue = (
13185
- jsValue,
13186
- { controlType, type, inputMode },
13187
- ) => {
13188
- if (controlType === "input" || controlType === "picker") {
13189
- if (type === "datetime-local") {
13190
- return asDatetimeLocalString(jsValue);
13191
- }
13192
- if (
13193
- type === "number" ||
13194
- type === "range" ||
13195
- inputMode === "numeric" ||
13196
- inputMode === "decimal"
13197
- ) {
13198
- return asNumberString(jsValue);
13199
- }
13200
- if (type === "color") {
13201
- return asColorString(jsValue);
13202
- }
13203
- return asInputValue(jsValue);
13204
- }
13205
- return jsValue;
13206
- };
13207
- // As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
13208
- // datetime-local does not support timezones
13209
- const asDatetimeLocalString = (dateTimeString) => {
13210
- const date = new Date(dateTimeString);
13211
- if (isNaN(date.getTime())) {
13212
- return dateTimeString;
13213
- }
13214
- const year = date.getFullYear();
13215
- const month = String(date.getMonth() + 1).padStart(2, "0");
13216
- const day = String(date.getDate()).padStart(2, "0");
13217
- const hours = String(date.getHours()).padStart(2, "0");
13218
- const minutes = String(date.getMinutes()).padStart(2, "0");
13219
- const seconds = String(date.getSeconds()).padStart(2, "0");
13220
- return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
13221
- };
13222
- const asNumberString = (jsValue) => {
13223
- if (jsValue === undefined) {
13224
- return "";
13225
- }
13226
- return jsValue;
13227
- };
13228
- // Browser requires a non-empty value for <input type="color">.
13229
- // When our logical value is empty we give it #000000 so it doesn't choke.
13230
- // The UI uses the original (possibly empty) value to show the checkerboard.
13231
- const asColorString = (jsValue) => {
13232
- return jsValue || "#000000";
13233
- };
13234
- const asInputValue = (jsValue) => {
13235
- if (jsValue === undefined) {
13236
- return "";
13237
- }
13238
- return jsValue;
13239
- };
13240
-
13241
- /**
13242
- * Reads the current logical JS value from a control host DOM element.
13243
- *
13244
- * Handles all navi control host element types:
13245
- * - `<button>` — reads via `navi_get_value` custom event, falls back to `button.value`
13246
- * - `<input type="number|range">` — parses as a number, returns `undefined` when empty
13247
- * - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
13248
- * via `navi_get_value` custom event (to preserve the original JS type of the value prop)
13249
- * - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
13250
- * - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
13251
- * - All other inputs — returns `input.value` as a string
13252
- *
13253
- * @param {HTMLElement} controlHost - The control host DOM element to read from.
13254
- * @returns {any} The current logical value of the control.
13255
- */
13256
- const readControlValue = (controlHost) => {
13257
- if (
13258
- controlHost.tagName === "BUTTON" ||
13259
- controlHost.getAttribute("role") === "button"
13260
- ) {
13261
- return readValueFromButton(controlHost);
13262
- }
13263
- if (controlHost.tagName === "INPUT") {
13264
- // important: input.type = "navi_js"; followed by input.type; returns "text"
13265
- // so use getAttribute
13266
- const type = controlHost.getAttribute("type");
13267
-
13268
- if (
13269
- type === "number" ||
13270
- type === "range" ||
13271
- controlHost.inputMode === "numeric" ||
13272
- controlHost.inputMode === "decimal"
13273
- ) {
13274
- return readNumberFromInput(controlHost);
13275
- }
13276
- if (type === "color") {
13277
- return readValueFromControlHost(controlHost);
13278
- }
13279
- if (type === "checkbox" || type === "radio") {
13280
- return readValueFromCheckableInput(controlHost);
13281
- }
13282
- if (type === "datetime-local") {
13283
- return readDatetimeLocalFromInput(controlHost);
13284
- }
13285
- if (type === "navi_js") {
13286
- return getUIStateFromElement(controlHost);
13287
- }
13288
- return readValueFromInput(controlHost);
13289
- }
13290
- if (controlHost.hasAttribute("navi-control-host")) {
13291
- // Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
13292
- return readValueFromControlHost(controlHost);
13293
- }
13294
- return readValueFromElement(controlHost);
13295
- };
13296
- const readValueFromControlHost = (controlHost) => {
13297
- return readValueFromNaviCustomEvent(controlHost, controlHost.value);
13298
- };
13299
- const readValueFromButton = (button) => {
13300
- return readValueFromControlHost(button);
13301
- };
13302
- const readDatetimeLocalFromInput = (input) => {
13303
- const localDateTimeString = input.value;
13304
- if (localDateTimeString === "") {
13305
- return "";
13306
- }
13307
- const localDate = new Date(localDateTimeString);
13308
- if (isNaN(localDate.getTime())) {
13309
- return localDateTimeString;
13310
- }
13311
- return localDate.toISOString();
13312
- };
13313
- const readNumberFromInput = (input) => {
13314
- const numberString = input.value;
13315
- if (numberString === "") {
13316
- return "";
13317
- }
13318
- const asNumber = Number(numberString);
13319
- if (isNaN(asNumber)) {
13320
- return numberString;
13321
- }
13322
- return asNumber;
13323
- };
13324
- const readValueFromCheckableInput = (input) => {
13325
- const checked = input.checked;
13326
- if (!checked) {
13327
- return undefined;
13328
- }
13329
- return readValueFromControlHost(input);
13330
- };
13331
- const readValueFromInput = (input) => {
13332
- const value = input.value;
13333
- return value;
13334
- };
13335
- const readValueFromElement = (element) => {
13336
- const value = element.value;
13337
- return value;
13338
- };
13339
- const readValueFromNaviCustomEvent = (field, fallback) => {
13340
- // prefer the value given as prop (respect original type, browser would convert to string)
13341
- let responded;
13342
- let value;
13343
- dispatchCustomEvent(field, "navi_get_value", {
13344
- respondWith: (jsValue) => {
13345
- responded = true;
13346
- value = jsValue;
13347
- },
13348
- });
13349
- if (responded) {
13350
- return value;
13351
- }
13352
- return fallback;
13353
- };
13354
-
13355
- // In-memory registry of all mounted ui state controllers keyed by their id.
13356
- // Allows direct controller access without dispatching DOM events — used by external
13357
- // callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
13358
- const controllersById = new Map();
13359
-
13360
- // In-memory registry for radio controllers, keyed by input name.
13361
- // Allows radio sibling unchecking without querying the DOM — necessary when
13362
- // items are virtualized and their DOM element may not exist at the time.
13363
- // Form scoping is reproduced by comparing parentUIStateController references.
13364
- const radioControllersByName = new Map();
13365
-
13366
- // Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
13367
- // When a value is an object/array, we store it here and write a reference string to the DOM
13368
- // instead of "[object Object]". Console-inspectable via window.__navi_js('id').
13369
- // The controller id is used as key — if the controller has no id, the value is not registered.
13370
- const naviJsRegistry = new Map();
13371
-
13372
- const getUIStateControllerById = (id) => controllersById.get(id);
13373
- const getRadioSiblings = (radioUIStateController) => {
13374
- const siblings = radioControllersByName.get(radioUIStateController.name);
13375
- return siblings;
13376
- };
13377
-
13378
- const toDomValue = (jsValue, { controlType, id, type, inputMode }) => {
13379
- const domValue = asControlHostValue(jsValue, {
13380
- controlType,
13381
- type,
13382
- inputMode,
13383
- });
13384
- if (isSerializableAsDomValue(domValue)) {
13385
- return domValue;
13386
- }
13387
- naviJsRegistry.set(id, domValue);
13388
- return `window.__navi_js('${id}')`;
13389
- };
13390
-
13391
- window.__navi_js = (id) => naviJsRegistry.get(id);
13392
- const isSerializableAsDomValue = (value) => {
13393
- if (value === null || value === undefined) {
13394
- return true;
13395
- }
13396
- const type = typeof value;
13397
- return type === "string" || type === "number" || type === "boolean";
13398
- };
13399
-
13400
- const onUIStateControllerCreated = (uiStateController) => {
13401
- const { id, name, controlType } = uiStateController;
13402
- if (id) {
13403
- controllersById.set(id, uiStateController);
13404
- }
13405
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
13406
- if (proxyFor) {
13407
- proxyControllerByRealInputId.set(proxyFor, uiStateController);
13408
- }
13409
- if (
13410
- controlType === "input" &&
13411
- uiStateController.props.type === "radio" &&
13412
- name
13413
- ) {
13414
- let set = radioControllersByName.get(name);
13415
- if (!set) {
13416
- set = new Set();
13417
- radioControllersByName.set(name, set);
13418
- }
13419
- set.add(uiStateController);
13420
- }
13421
- };
13422
- const onUIStateControllerDestroyed = (uiStateController) => {
13423
- const { id, name, controlType } = uiStateController;
13424
- if (id) {
13425
- controllersById.delete(id);
13426
- naviJsRegistry.delete(id);
13427
- }
13428
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
13429
- if (proxyFor) {
13430
- proxyControllerByRealInputId.delete(proxyFor);
13431
- }
13432
- if (
13433
- controlType === "input" &&
13434
- uiStateController.controlHostProps.type === "radio" &&
13435
- name
13436
- ) {
13437
- const set = radioControllersByName.get(name);
13438
- if (set) {
13439
- set.delete(uiStateController);
13440
- if (set.size === 0) {
13441
- radioControllersByName.delete(name);
13442
- }
13443
- }
13444
- }
13445
- };
13446
-
13447
- /**
13448
- * Controller-based equivalent of findControlProxyTarget.
13449
- * Given a proxy controller, returns the real control's controller.
13450
- * Finds the target by walking the parent controller's children — no DOM queries.
13451
- * Returns `null` when the controller is not a proxy or the target is not found.
13452
- */
13453
- const findControlProxyTargetController = (controller) => {
13454
- const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
13455
- if (!proxyFor) {
13456
- return null;
13457
- }
13458
- return getUIStateControllerById(proxyFor) ?? null;
13459
- };
13460
-
13461
- // Reverse-lookup map: real-input id → proxy controller that references it via
13462
- // `navi-control-proxy-for`. Maintained on create/destroy so lookup is O(1).
13463
- const proxyControllerByRealInputId = new Map();
13464
- const findProxyController = (realInputId) => {
13465
- if (!realInputId) {
13466
- return null;
13467
- }
13468
- return proxyControllerByRealInputId.get(realInputId) ?? null;
13469
- };
13470
-
13471
13549
  const CONSTRAINT_NAME_TO_PROP = {
13472
13550
  disabled: "disabledMessage",
13473
13551
  required: "requiredMessage",
@@ -13568,7 +13646,7 @@ installImportMetaCssBuild(import.meta);/**
13568
13646
  * - Arrow automatically shows when pointing at a valid anchor element
13569
13647
  * - Centers in viewport when no anchor element provided or anchor is too big
13570
13648
  */
13571
- const css$V = /* css */`
13649
+ const css$W = /* css */`
13572
13650
  @layer navi {
13573
13651
  .navi_callout {
13574
13652
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -13807,7 +13885,7 @@ const openCallout = (message, {
13807
13885
  skipFocus = false,
13808
13886
  debug = () => {}
13809
13887
  } = {}) => {
13810
- import.meta.css = [css$V, "@jsenv/navi/src/control/rules/callout/callout.js"];
13888
+ import.meta.css = [css$W, "@jsenv/navi/src/control/rules/callout/callout.js"];
13811
13889
  if (debug === true) {
13812
13890
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
13813
13891
  }
@@ -14408,12 +14486,12 @@ const positionCallout = (calloutElement, anchorElement, {
14408
14486
  } else if (anchorElement.hasAttribute("data-callout-point-to-content-box")) {
14409
14487
  alignToAnchorBox = "content-box";
14410
14488
  } else {
14411
- // Smart default: inputs and buttons are tight boxes where border-box makes sense.
14489
+ // Smart default: form controls and buttons are tight boxes where border-box makes sense.
14412
14490
  // For everything else (labels, divs, fieldsets…) content-box maximizes the chance
14413
14491
  // the arrow points at visible text rather than the outer padding/border.
14414
14492
  const controHost = findControlHost(anchorElement) || anchorElement;
14415
14493
  const tagName = controHost.tagName;
14416
- if (tagName === "INPUT" || tagName === "BUTTON" || tagName === "FIELDSET") {
14494
+ if (tagName === "INPUT" || tagName === "SELECT" || tagName === "BUTTON" || tagName === "FIELDSET") {
14417
14495
  alignToAnchorBox = "border-box";
14418
14496
  } else {
14419
14497
  alignToAnchorBox = "content-box";
@@ -17794,6 +17872,108 @@ const useExecuteAction = (
17794
17872
  return executeAction;
17795
17873
  };
17796
17874
 
17875
+ /**
17876
+ * A control placed inside a region that expands on click — a `<summary>`, an
17877
+ * accordion header carrying `aria-expanded` — has its click read twice: once by
17878
+ * the control it was aimed at, once by the region around it. The second reading
17879
+ * is never wanted; a menu opened from a collapsed row should not also unfold the
17880
+ * row.
17881
+ *
17882
+ * Cancelling the click is the only way to stop the region: a `<summary>` runs
17883
+ * its default action after the propagation, so `stopPropagation` does not reach
17884
+ * it. And it can only be done once the control has taken the click for itself —
17885
+ * navi refuses an interaction on an already-cancelled event (see
17886
+ * `onRequestInteraction`), so cancelling any earlier silences the control
17887
+ * instead of the region.
17888
+ *
17889
+ * That moment — right after an interaction was allowed — only exists inside
17890
+ * navi, which is why the cancellation lives here rather than in application code.
17891
+ */
17892
+
17893
+ const CLICK_TO_EXPAND_SELECTOR = "summary, [aria-expanded]";
17894
+
17895
+ /**
17896
+ * Cancels `event` when the control consumed a click that a surrounding
17897
+ * click-to-expand region would otherwise read as "unfold me".
17898
+ *
17899
+ * Does nothing when cancelling the click would also cancel what the control
17900
+ * itself does with it (a link navigating, a checkbox toggling): there, the two
17901
+ * behaviours cannot be separated and the control's own comes first.
17902
+ */
17903
+ const preventClickToExpand = (element, event) => {
17904
+ if (!event || event.type !== "click") {
17905
+ return;
17906
+ }
17907
+ if (event.defaultPrevented) {
17908
+ return;
17909
+ }
17910
+ if (!clickDefaultActionIsInert(element, event)) {
17911
+ return;
17912
+ }
17913
+ const parentElement = element.parentElement;
17914
+ if (!parentElement) {
17915
+ return;
17916
+ }
17917
+ // From the parent: a control that opens something carries its own
17918
+ // `aria-expanded` and would find itself.
17919
+ const clickToExpandRegion = parentElement.closest(CLICK_TO_EXPAND_SELECTOR);
17920
+ if (!clickToExpandRegion) {
17921
+ return;
17922
+ }
17923
+ event.preventDefault();
17924
+ };
17925
+
17926
+ const clickDefaultActionIsInert = (element, event) => {
17927
+ if (!isInertOnClick(element)) {
17928
+ return false;
17929
+ }
17930
+ // The activation belongs to what was clicked, which can be deeper than the
17931
+ // control host (a button inside it) or above it (a label wrapping it).
17932
+ const { target } = event;
17933
+ if (target && target !== element && target.nodeType === 1) {
17934
+ let ancestor = target;
17935
+ while (ancestor) {
17936
+ if (!isInertOnClick(ancestor)) {
17937
+ return false;
17938
+ }
17939
+ ancestor = ancestor.parentElement;
17940
+ }
17941
+ }
17942
+ return true;
17943
+ };
17944
+
17945
+ const NON_INERT_INPUT_TYPE_SET = new Set([
17946
+ "checkbox",
17947
+ "radio",
17948
+ "submit",
17949
+ "reset",
17950
+ "image",
17951
+ "file",
17952
+ ]);
17953
+
17954
+ const isInertOnClick = (element) => {
17955
+ const { tagName } = element;
17956
+ if (tagName === "A" || tagName === "AREA") {
17957
+ return !element.hasAttribute("href");
17958
+ }
17959
+ if (tagName === "LABEL") {
17960
+ // A label forwards the click to its control, whose activation would be
17961
+ // cancelled along with the click.
17962
+ return false;
17963
+ }
17964
+ if (tagName === "INPUT") {
17965
+ return !NON_INERT_INPUT_TYPE_SET.has(element.type);
17966
+ }
17967
+ if (tagName === "BUTTON") {
17968
+ return element.type === "button";
17969
+ }
17970
+ if (tagName === "SELECT") {
17971
+ // The click opens the option list; cancelling it leaves the select shut.
17972
+ return false;
17973
+ }
17974
+ return true;
17975
+ };
17976
+
17797
17977
  const BUSY_CONSTRAINT = {
17798
17978
  name: "busy",
17799
17979
  messageAttribute: "data-busy-message",
@@ -18162,6 +18342,9 @@ const onRequestInteraction = (
18162
18342
  debugInteraction(event, `"${name}" allowed`);
18163
18343
  allowed?.();
18164
18344
  always?.();
18345
+ // The click served this control; it must not serve a second time whatever
18346
+ // unfolds around it (see click_to_expand.js).
18347
+ preventClickToExpand(controlHost, event);
18165
18348
  return true;
18166
18349
  };
18167
18350
 
@@ -19606,6 +19789,78 @@ createContext();
19606
19789
  const ActionContext = createContext();
19607
19790
  const ActionRequesterContext = createContext();
19608
19791
 
19792
+ /**
19793
+ * How a control tells the labels pointing at it what it is (disabled, readOnly,
19794
+ * required) and when it goes away.
19795
+ *
19796
+ * A label linked to its control by id has no DOM relationship to walk: the two
19797
+ * only know each other's id. A non-native control has no `element.labels`
19798
+ * either, so the only way to go from the control to its labels through the DOM
19799
+ * is to ask the whole document for `label[for="…"]` — once per control, on a
19800
+ * document that every mounted control makes bigger.
19801
+ *
19802
+ * The link is held here instead. The control publishes its state under its own
19803
+ * id; a label subscribes to the id it points at. Order does not matter —
19804
+ * whichever mounts second finds what the first left, so a label written after
19805
+ * its control is told just as much as one written before it.
19806
+ *
19807
+ * A label that WRAPS its control has the native relationship already
19808
+ * (`element.labels`) and is notified through a DOM event instead — see
19809
+ * `getAssociatedLabels` in control_hooks.jsx. Both channels carry the same
19810
+ * values and land on the same setters, so a control reachable through both is
19811
+ * simply told twice.
19812
+ */
19813
+
19814
+ const stateByControlId = new Map();
19815
+ const callbackSetByControlId = new Map();
19816
+
19817
+ const publishControlStateToLabels = (controlId, controlState) => {
19818
+ if (!controlId) {
19819
+ return;
19820
+ }
19821
+ stateByControlId.set(controlId, controlState);
19822
+ const callbackSet = callbackSetByControlId.get(controlId);
19823
+ if (callbackSet) {
19824
+ for (const callback of callbackSet) {
19825
+ callback(controlState);
19826
+ }
19827
+ }
19828
+ };
19829
+
19830
+ const unpublishControlStateToLabels = (controlId) => {
19831
+ if (!controlId) {
19832
+ return;
19833
+ }
19834
+ stateByControlId.delete(controlId);
19835
+ const callbackSet = callbackSetByControlId.get(controlId);
19836
+ if (callbackSet) {
19837
+ for (const callback of callbackSet) {
19838
+ callback(null);
19839
+ }
19840
+ }
19841
+ };
19842
+
19843
+ /**
19844
+ * Subscribes to the state published by the control identified by `controlId`.
19845
+ * The callback is called right away with the current state (or `null` when no
19846
+ * such control is mounted), then on every change. Returns the teardown.
19847
+ */
19848
+ const subscribeToControlState = (controlId, callback) => {
19849
+ let callbackSet = callbackSetByControlId.get(controlId);
19850
+ if (!callbackSet) {
19851
+ callbackSet = new Set();
19852
+ callbackSetByControlId.set(controlId, callbackSet);
19853
+ }
19854
+ callbackSet.add(callback);
19855
+ callback(stateByControlId.get(controlId) ?? null);
19856
+ return () => {
19857
+ callbackSet.delete(callback);
19858
+ if (callbackSet.size === 0) {
19859
+ callbackSetByControlId.delete(controlId);
19860
+ }
19861
+ };
19862
+ };
19863
+
19609
19864
  /**
19610
19865
  * Named presets for the `charGuard` prop.
19611
19866
  * Each value is a regex character class (including the [ ] delimiters).
@@ -20209,15 +20464,17 @@ const useUIStateController = (
20209
20464
  // later through a React re-render — visible as e.g. two radios
20210
20465
  // appearing checked at once between the real input update and the
20211
20466
  // next render (radio_sibling_uncheck case).
20212
- const proxyController = findProxyController(s.id);
20213
- if (proxyController) {
20214
- // Find any mounted controller that declared itself as a proxy for this one.
20215
- // Communicates directly to the proxy controller — no DOM query needed.
20216
- const mirrorEvent = new CustomEvent("proxy_mirror_state", {
20217
- detail: {},
20218
- });
20219
- chainEvent(mirrorEvent, e);
20220
- proxyController.setUIState(newUIState, mirrorEvent);
20467
+ // Every mounted controller that declared itself as a proxy for this
20468
+ // one. Communicates directly to them — no DOM query needed.
20469
+ const proxyControllerSet = findProxyControllers(s.id);
20470
+ if (proxyControllerSet) {
20471
+ for (const proxyController of proxyControllerSet) {
20472
+ const mirrorEvent = new CustomEvent("proxy_mirror_state", {
20473
+ detail: {},
20474
+ });
20475
+ chainEvent(mirrorEvent, e);
20476
+ proxyController.setUIState(newUIState, mirrorEvent);
20477
+ }
20221
20478
  }
20222
20479
  }
20223
20480
  if (isInternalEvent(e)) {
@@ -20334,8 +20591,18 @@ const useUIStateController = (
20334
20591
  );
20335
20592
  syntheticInputFired = true;
20336
20593
  }
20594
+ } else if (el.tagName === "SELECT") {
20595
+ debugUIState(
20596
+ e,
20597
+ `dispatching synthetic input event for select "${newUIState}"`,
20598
+ );
20599
+ // A plain Event, not an InputEvent: that is what the browser
20600
+ // itself fires on a select, and input_effect reads the value off
20601
+ // the element anyway.
20602
+ el.dispatchEvent(new Event("input", { bubbles: true }));
20603
+ syntheticInputFired = true;
20337
20604
  }
20338
- // TODO: select, textarea
20605
+ // TODO: textarea
20339
20606
  }
20340
20607
  }
20341
20608
  if (!syntheticInputFired) {
@@ -21824,6 +22091,61 @@ const useControlProps = (props, {
21824
22091
  }
21825
22092
  };
21826
22093
  }
22094
+ const enterToSend = e => {
22095
+ const control = e.currentTarget;
22096
+ return {
22097
+ name: "enter to send closest control group",
22098
+ bypassInteractivity: true,
22099
+ // allow to dispatch --navi-send even if readonly
22100
+ allowed: () => triggerNaviCommand(control, "--navi-send", e),
22101
+ // prevent dispatching click as result of this enter
22102
+ prevented: () => e.preventDefault()
22103
+ };
22104
+ };
22105
+ if (controlType === "select") {
22106
+ return {
22107
+ keyDown: e => {
22108
+ if (e.key === "Enter") {
22109
+ return enterToSend(e);
22110
+ }
22111
+ if (getKeyboardEventDefaultAction(e) === "activate") {
22112
+ // Space opens the list. Nothing has been chosen at that point, so
22113
+ // there is no ui action to trigger — only whether the list is
22114
+ // allowed to open at all.
22115
+ return {
22116
+ name: "keydown to open the option list",
22117
+ prevented: () => e.preventDefault()
22118
+ };
22119
+ }
22120
+ return null;
22121
+ },
22122
+ mouseDown: e => {
22123
+ // Same as the keydown above: opening the list is the interaction to
22124
+ // ask about, and refusing it is what keeps a read-only select shut.
22125
+ return {
22126
+ name: "mousedown to open the option list",
22127
+ prevented: () => e.preventDefault()
22128
+ };
22129
+ },
22130
+ input: e => {
22131
+ return {
22132
+ name: "input",
22133
+ allowed: () => syncUIStateWithDOM(e),
22134
+ // The keyboard moves the selection on a closed select, and the
22135
+ // platform's own list can hand back a choice, both before anything
22136
+ // was asked. A refused change puts the element back on the state it
22137
+ // never left.
22138
+ prevented: () => syncDomState(uiStateController.uiState, e)
22139
+ };
22140
+ },
22141
+ naviChange: e => {
22142
+ return {
22143
+ name: "navi_change",
22144
+ allowed: () => requestActionOnAllowed(e)
22145
+ };
22146
+ }
22147
+ };
22148
+ }
21827
22149
  const keyDownDefaultOnInput = e => {
21828
22150
  if (e.key === "Enter") {
21829
22151
  if (actionDebounce) {
@@ -21832,15 +22154,7 @@ const useControlProps = (props, {
21832
22154
  // Don't propagate to --navi-send, which would cause a double action call.
21833
22155
  return null;
21834
22156
  }
21835
- const input = e.currentTarget;
21836
- return {
21837
- name: "enter on input to send closest control group",
21838
- bypassInteractivity: true,
21839
- // allow to dispatch --navi-send even if input is readonly
21840
- allowed: () => triggerNaviCommand(input, "--navi-send", e),
21841
- // prevent dispatching click as result of this enter
21842
- prevented: () => e.preventDefault()
21843
- };
22157
+ return enterToSend(e);
21844
22158
  }
21845
22159
  return keyDownDefault(e);
21846
22160
  };
@@ -22236,7 +22550,7 @@ const createControlInfo = (props, {
22236
22550
  defaultStatePropName = "defaultOpen";
22237
22551
  stateInitial = props.open || props.defaultOpen;
22238
22552
  value = props.value || "open";
22239
- } else if (controlType === "picker") {
22553
+ } else if (controlType === "picker" || controlType === "select") {
22240
22554
  statePropName = "value";
22241
22555
  defaultStatePropName = "defaultValue";
22242
22556
  if (Object.hasOwn(props, "value")) {
@@ -22255,7 +22569,10 @@ const createControlInfo = (props, {
22255
22569
  stateInitial = undefined;
22256
22570
  }
22257
22571
  disabledSupported = true;
22258
- readOnlySupported = INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
22572
+ // A native <select> has no readonly attribute. What says it is read-only is
22573
+ // aria-readonly plus a refused interaction — see the select reactions in
22574
+ // getDefaultEventReactionDefinitions.
22575
+ readOnlySupported = controlType === "picker" && INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
22259
22576
  }
22260
22577
  return {
22261
22578
  controlType,
@@ -22581,39 +22898,47 @@ const useInteractiveProps = (props, {
22581
22898
  controlHostProps["inert"] = "";
22582
22899
  }
22583
22900
  }
22584
- // inform any associated label of our state (connected, disabled, readOnly,
22901
+ // Inform any associated label of our state (connected, disabled, readOnly,
22585
22902
  // required — a Label with requiredIndicator marks itself from it rather
22586
- // than being told twice what the control already knows)
22587
- // dispatched directly on the label works whether the label wraps the control
22588
- // (Field as label) or is a separate element linked via htmlFor (Label component)
22903
+ // than being told twice what the control already knows), through both
22904
+ // channels a label can be reached by: a DOM event on the labels the element
22905
+ // itself hands over (a wrapping <label>, or a label[for] on a native form
22906
+ // element), and a publication under this control's id for the labels that
22907
+ // only know it by that (see control_label_state.js).
22908
+ //
22909
+ // The id is remembered rather than re-read at unmount time: ref.current is
22910
+ // often already null by then, and the labels subscribed under that id would
22911
+ // stay told about a control that no longer exists.
22912
+ const publishedIdRef = useRef(null);
22589
22913
  useLayoutEffect(() => {
22590
22914
  const element = ref.current;
22591
22915
  if (!element) {
22592
22916
  return;
22593
22917
  }
22594
- const labels = getAssociatedLabels(element);
22595
22918
  const readOnlyForced = element.hasAttribute("data-readonly-forced");
22596
22919
  const readOnly = readOnlyForced ? false : readOnlyResolved;
22597
- for (const label of labels) {
22920
+ const controlState = {
22921
+ disabled: disabledResolved,
22922
+ readOnly,
22923
+ required: requiredResolved
22924
+ };
22925
+ for (const label of getAssociatedLabels(element)) {
22598
22926
  label.dispatchEvent(new CustomEvent("navi_control_state", {
22599
- detail: {
22600
- disabled: disabledResolved,
22601
- readOnly,
22602
- required: requiredResolved
22603
- }
22927
+ detail: controlState
22604
22928
  }));
22605
22929
  }
22930
+ publishedIdRef.current = element.id;
22931
+ publishControlStateToLabels(element.id, controlState);
22606
22932
  }, [disabledResolved, readOnlyResolved, requiredResolved, ref]);
22607
22933
  useLayoutEffect(() => {
22608
22934
  return () => {
22609
22935
  const element = ref.current;
22610
- if (!element) {
22611
- return;
22612
- }
22613
- const labels = getAssociatedLabels(element);
22614
- for (const label of labels) {
22615
- label.dispatchEvent(new CustomEvent("navi_control_disconnected"));
22936
+ if (element) {
22937
+ for (const label of getAssociatedLabels(element)) {
22938
+ label.dispatchEvent(new CustomEvent("navi_control_disconnected"));
22939
+ }
22616
22940
  }
22941
+ unpublishControlStateToLabels(publishedIdRef.current);
22617
22942
  };
22618
22943
  }, []);
22619
22944
  }
@@ -22863,29 +23188,19 @@ const splitControlProps = props => {
22863
23188
  }
22864
23189
  return [controlRootProps, controlHostProps];
22865
23190
  };
23191
+
23192
+ // The labels the DOM itself can hand over: a wrapping <label>, or a label[for]
23193
+ // pointing at a native form element. Everything else — a label[for] on a
23194
+ // non-native control — goes through control_label_state.js instead, which knows
23195
+ // the pairing without asking the document for it.
22866
23196
  const getAssociatedLabels = element => {
22867
- if (!element) {
23197
+ if (!element || !element.labels) {
22868
23198
  return [];
22869
23199
  }
22870
- // const closestPicker = element.closest('[navi-control="picker"]');
22871
- // const insidePicker = closestPicker && element !== closestPicker;
22872
- // const formElement = insidePicker ? closestPicker : element;
22873
- const formElement = element;
22874
- // Native form elements expose .labels directly
22875
- if (formElement.labels && formElement.labels.length > 0) {
22876
- return Array.from(formElement.labels);
22877
- }
22878
- const id = formElement.id;
22879
- if (id) {
22880
- const byId = Array.from(document.querySelectorAll(`label[for="${CSS.escape(id)}"]`));
22881
- if (byId.length > 0) {
22882
- return byId;
22883
- }
22884
- }
22885
- return [];
23200
+ return Array.from(element.labels);
22886
23201
  };
22887
23202
 
22888
- installImportMetaCssBuild(import.meta);const css$U = /* css */`
23203
+ installImportMetaCssBuild(import.meta);const css$V = /* css */`
22889
23204
  @layer navi {
22890
23205
  .navi_button {
22891
23206
  --button-border-radius: var(--navi-control-border-radius);
@@ -23278,7 +23593,7 @@ installImportMetaCssBuild(import.meta);const css$U = /* css */`
23278
23593
  }
23279
23594
  `;
23280
23595
  const ButtonUI = props => {
23281
- import.meta.css = [css$U, "@jsenv/navi/src/control/input/button_ui.jsx"];
23596
+ import.meta.css = [css$V, "@jsenv/navi/src/control/input/button_ui.jsx"];
23282
23597
  const {
23283
23598
  ref,
23284
23599
  // href/link
@@ -24690,6 +25005,10 @@ const createOpenController = (
24690
25005
  const controller = {
24691
25006
  opened: false,
24692
25007
  openEffect: null,
25008
+ // Set by the controlled element (see popup_content_mount.js) when its
25009
+ // content is still waiting for a first open to be built. Called below,
25010
+ // before openEffect, so the popup measures and positions the real thing.
25011
+ mountContent: null,
24693
25012
  open: (e, detail) => {
24694
25013
  if (controller.opened || !controller.openEffect) {
24695
25014
  return;
@@ -24743,6 +25062,10 @@ const createOpenController = (
24743
25062
  }
24744
25063
  };
24745
25064
  };
25065
+ // After prepareFocusTransfer, which has to record what held the focus
25066
+ // before anything inside the popup can claim it, and before openEffect,
25067
+ // which measures the popup to place it.
25068
+ controller.mountContent?.();
24746
25069
  const openEffectReturnValue =
24747
25070
  controller.openEffect(requestOpenEvent) || null;
24748
25071
  openEffectCleanup = (closeEvent) => {
@@ -24936,6 +25259,84 @@ const useOpenPropsEffectOnOpenController = (openController, props) => {
24936
25259
  }, [open]);
24937
25260
  };
24938
25261
 
25262
+ /**
25263
+ * Runs `fn` and commits whatever it re-renders before returning, instead of
25264
+ * letting Preact batch it into the next microtask. Layout effects of what gets
25265
+ * mounted run inside the call too, exactly as they would on any other commit.
25266
+ *
25267
+ * For the caller that has to read the DOM it just asked for — measuring an
25268
+ * element whose content it mounts in the same breath — and cannot wait a tick
25269
+ * to do it, because what comes after is a browser event still in flight
25270
+ * (preventDefault, focus placement) that no longer accepts being answered late.
25271
+ *
25272
+ * `options.debounceRendering` is Preact's own hook for deciding *when* the
25273
+ * render queue drains; swapping it for "right now" for the duration of the call
25274
+ * is exactly how preact/compat implements React's flushSync. Reserve it for the
25275
+ * case above: rendering synchronously in the middle of an event gives up the
25276
+ * batching that makes several state changes one commit.
25277
+ */
25278
+ const flushSyncRendering = (fn) => {
25279
+ const debounceRenderingPrevious = options.debounceRendering;
25280
+ options.debounceRendering = (drainRenderQueue) => {
25281
+ drainRenderQueue();
25282
+ };
25283
+ try {
25284
+ fn();
25285
+ } finally {
25286
+ options.debounceRendering = debounceRenderingPrevious;
25287
+ }
25288
+ };
25289
+
25290
+ /**
25291
+ * When a popup builds what it holds.
25292
+ *
25293
+ * A closed popup shows nothing, focuses nothing, and answers nothing: what it
25294
+ * holds is out of reach until it opens. Building that content at mount time
25295
+ * means a page carrying a handful of closed popups pays, on the very render
25296
+ * that decides how fast it appears, for content nobody has asked for — and
25297
+ * pays again on every subsequent measurement, since each of those nodes makes
25298
+ * the document the rest of the page queries bigger.
25299
+ *
25300
+ * So the content is built when the popup first opens, and stays built from
25301
+ * then on: closing is not throwing away, and a reopened popup finds its scroll
25302
+ * position, its half-typed form and its list state where it left them.
25303
+ *
25304
+ * It is built synchronously, from inside `openController.open()` and before
25305
+ * `openEffect` runs (see open_controller.js), so the popup still measures real
25306
+ * content when it positions and animates itself, and so anything inside it
25307
+ * still observes the opening the way it always did — mounted while the popup
25308
+ * reads as closed, told it opened right after (see
25309
+ * use_displayed_layout_effect.js).
25310
+ *
25311
+ * `mountWhenClosed` is for content something else depends on before any of
25312
+ * this: a value the popup's owner reads off its own children, fields a form
25313
+ * around it collects on submit, a size measured from outside.
25314
+ */
25315
+
25316
+
25317
+ const usePopupContentMount = (
25318
+ openController,
25319
+ { children, mountWhenClosed },
25320
+ ) => {
25321
+ const [contentMounted, setContentMounted] = useState(
25322
+ () => Boolean(mountWhenClosed) || openController.opened,
25323
+ );
25324
+ openController.mountContent = contentMounted
25325
+ ? null
25326
+ : () => {
25327
+ flushSyncRendering(() => {
25328
+ setContentMounted(true);
25329
+ });
25330
+ };
25331
+ useLayoutEffect(() => {
25332
+ if (mountWhenClosed) {
25333
+ setContentMounted(true);
25334
+ }
25335
+ }, [mountWhenClosed]);
25336
+
25337
+ return contentMounted ? children : null;
25338
+ };
25339
+
24939
25340
  /**
24940
25341
  * Entry/exit animation CSS shared by Popover and Dialog.
24941
25342
  *
@@ -25406,7 +25807,7 @@ installImportMetaCssBuild(import.meta);/**
25406
25807
  * reaches the real container.
25407
25808
  */
25408
25809
  let openLocalDialogCount = 0;
25409
- const css$T = /* css */`
25810
+ const css$U = /* css */`
25410
25811
  @layer navi {
25411
25812
  .navi_dialog {
25412
25813
  /* Min gap between the dialog and the edges of its container. Written
@@ -25732,8 +26133,8 @@ const css$T = /* css */`
25732
26133
  * the screen and a centered box ends up both cramped and out of thumb
25733
26134
  * reach, while under a mouse the centered box is already the right shape —
25734
26135
  * hence a prop that only ever does something on touch. It supplies defaults
25735
- * for `positionArea`, `marginWithContainer` and `expandX`, so any of the
25736
- * three can still be pinned explicitly. Keyed off `(pointer: coarse)` (the
26136
+ * for `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`,
26137
+ * so any of them can still be pinned explicitly. Keyed off `(pointer: coarse)` (the
25737
26138
  * input device, not a width breakpoint — a narrow desktop window is still a
25738
26139
  * mouse) via `coarsePointerSignal`, so it re-resolves live.
25739
26140
  * @param {string} [props.positionArea="center"] - Where to dock the dialog
@@ -25770,7 +26171,7 @@ const css$T = /* css */`
25770
26171
  * A `layer="local"` dialog always locks its own positioned ancestor's
25771
26172
  * scroll while open (its backdrop only covers the scrollport, so scrolling
25772
26173
  * there would reveal uncovered content); this prop extends the lock to the
25773
- * whole page.
26174
+ * whole page. Defaults to `true` for a dialog docked by `dockedOnTouch`.
25774
26175
  * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`} [props.animation]
25775
26176
  * - `true`/`"auto"` resolves to `"scaling"` for a centered `positionArea`,
25776
26177
  * or a concrete `"slide-from-*"` direction otherwise. Any other explicit
@@ -25813,10 +26214,15 @@ const css$T = /* css */`
25813
26214
  * open controller (see `open_controller.js`) for a caller that wants to
25814
26215
  * drive open/close itself instead of `open`/`defaultOpen`/`onClose` (used
25815
26216
  * by `picker_custom.jsx`).
26217
+ * @param {boolean} [props.mountWhenClosed] - Builds `children` right away
26218
+ * instead of waiting for the first open (see popup_content_mount.js). For
26219
+ * content something depends on while the popup is still closed: a value read
26220
+ * off it, fields a surrounding form collects on submit, a size measured from
26221
+ * outside.
25816
26222
  * @param {import("ignore:preact").ComponentChildren} props.children
25817
26223
  */
25818
26224
  const Dialog = props => {
25819
- import.meta.css = [css$T, "@jsenv/navi/src/layout/dialog.jsx"];
26225
+ import.meta.css = [css$U, "@jsenv/navi/src/layout/dialog.jsx"];
25820
26226
  if (props.openController) {
25821
26227
  return jsx(ControlledDialog, {
25822
26228
  ...props
@@ -25939,7 +26345,11 @@ const DialogLocal = props => {
25939
26345
  const DOCKED = {
25940
26346
  positionArea: "bottom",
25941
26347
  marginWithContainer: 0,
25942
- expandX: true
26348
+ expandX: true,
26349
+ // A sheet resting on the bottom edge is dragged with a thumb, and a drag that
26350
+ // runs past its own edge must not land on the page behind it: the same
26351
+ // reasoning as "bottom" above, applied to the gesture instead of the shape.
26352
+ scrollCapture: true
25943
26353
  };
25944
26354
 
25945
26355
  // The first control inside `dialogEl` that is mid-action, if any. Walks the
@@ -25985,7 +26395,7 @@ const useDialogProps = props => {
25985
26395
  // there's no native inert-ing, so the real backdrop below is what
25986
26396
  // actually makes "capture"/"none" behave the same way here too.
25987
26397
  pointerInteractionOutsideEffect = "close",
25988
- scrollCapture,
26398
+ scrollCapture: scrollCaptureProp,
25989
26399
  animation,
25990
26400
  // Only ever affects --anchor-width/--anchor-height (see this file's top
25991
26401
  // comment) — Dialog's own positioning is never relative to it.
@@ -26000,9 +26410,14 @@ const useDialogProps = props => {
26000
26410
  // instead, so it's read here rather than left in `rest`.
26001
26411
  autoFocus = "last-resort",
26002
26412
  onKeyDown,
26003
- children,
26413
+ children: childrenProp,
26414
+ mountWhenClosed,
26004
26415
  ...rest
26005
26416
  } = props;
26417
+ const children = usePopupContentMount(openController, {
26418
+ children: childrenProp,
26419
+ mountWhenClosed
26420
+ });
26006
26421
  const isModal = layer === "top";
26007
26422
  const ref = props.ref;
26008
26423
  // Only touch changes anything: with a mouse a dialog already wants to be the
@@ -26020,6 +26435,7 @@ const useDialogProps = props => {
26020
26435
  const expandXUnset = expand === undefined && expandXProp === undefined;
26021
26436
  const expandX = expandXUnset ? isDocked && DOCKED.expandX : Boolean(expand) || Boolean(expandXProp);
26022
26437
  const expandY = Boolean(expand) || Boolean(expandYProp);
26438
+ const scrollCapture = scrollCaptureProp ?? (isDocked ? DOCKED.scrollCapture : false);
26023
26439
  const backdropRef = useRef();
26024
26440
  // Disarms a still-pending backdrop hide from a previous close (see
26025
26441
  // armPointerDownOutsideClose below) — same pattern as popover.jsx's own.
@@ -26579,8 +26995,13 @@ const DIALOG_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible",
26579
26995
 
26580
26996
  // Lets consumers pass animationDuration="0.5s" as a regular prop; Box maps
26581
26997
  // it to the CSS var for us (see box.jsx's styleCSSVars handling).
26998
+ // borderRadius goes through --dialog-border-radius rather than the
26999
+ // border-radius property itself so the flush-corner rules above (a plain
27000
+ // stylesheet) can still square the corners that land on the container's own —
27001
+ // an inline border-radius would outrank them.
26582
27002
  const DIALOG_STYLE_CSS_VARS = {
26583
27003
  animationDuration: "--popup-animation-duration",
27004
+ borderRadius: "--dialog-border-radius",
26584
27005
  minWidth: "--dialog-min-width",
26585
27006
  maxWidth: "--dialog-max-width",
26586
27007
  minHeight: "--dialog-min-height",
@@ -26634,7 +27055,7 @@ installImportMetaCssBuild(import.meta);/**
26634
27055
  * and applied.
26635
27056
  */
26636
27057
  let openLocalPopoverCount = 0;
26637
- const css$S = /* css */`
27058
+ const css$T = /* css */`
26638
27059
  @layer navi {
26639
27060
  .navi_popover {
26640
27061
  /* soft: user-configurable preferred max-height. Kept as a *default*
@@ -27001,10 +27422,15 @@ const css$S = /* css */`
27001
27422
  * open controller (see `open_controller.js`) for a caller that wants to
27002
27423
  * drive open/close itself instead of `open`/`defaultOpen`/`onClose` (used
27003
27424
  * by `picker_custom.jsx`/`side_panel.jsx`).
27425
+ * @param {boolean} [props.mountWhenClosed] - Builds `children` right away
27426
+ * instead of waiting for the first open (see popup_content_mount.js). For
27427
+ * content something depends on while the popup is still closed: a value read
27428
+ * off it, fields a surrounding form collects on submit, a size measured from
27429
+ * outside.
27004
27430
  * @param {import("ignore:preact").ComponentChildren} props.children
27005
27431
  */
27006
27432
  const Popover = props => {
27007
- import.meta.css = [css$S, "@jsenv/navi/src/layout/popover.jsx"];
27433
+ import.meta.css = [css$T, "@jsenv/navi/src/layout/popover.jsx"];
27008
27434
  if (props.openController) {
27009
27435
  return jsx(ControlledPopover, {
27010
27436
  ...props
@@ -27185,9 +27611,14 @@ const usePopoverProps = props => {
27185
27611
  // instead, so it's read here rather than left in `rest`.
27186
27612
  autoFocus = "last-resort",
27187
27613
  onKeyDown,
27188
- children,
27614
+ children: childrenProp,
27615
+ mountWhenClosed,
27189
27616
  ...rest
27190
27617
  } = props;
27618
+ const children = usePopupContentMount(openController, {
27619
+ children: childrenProp,
27620
+ mountWhenClosed
27621
+ });
27191
27622
  const isTopLayer = layer === "top";
27192
27623
  const ref = props.ref;
27193
27624
  const backdropRef = useRef();
@@ -27968,7 +28399,7 @@ installImportMetaCssBuild(import.meta);/**
27968
28399
  * event, and a caller replacing the body entirely then has one protocol to
27969
28400
  * follow — `--navi-confirm` for yes, anything that closes for no.
27970
28401
  */
27971
- const css$R = /* css */`
28402
+ const css$S = /* css */`
27972
28403
  /* The width lives on the body rather than on the popup, so that custom
27973
28404
  content (which replaces this body entirely) sizes itself instead of
27974
28405
  inheriting a ceiling meant for a sentence-long question. */
@@ -28105,7 +28536,7 @@ const ConfirmPopup = ({
28105
28536
  onAnswer,
28106
28537
  onClosed
28107
28538
  }) => {
28108
- import.meta.css = [css$R, "@jsenv/navi/src/action/confirm_popup.jsx"];
28539
+ import.meta.css = [css$S, "@jsenv/navi/src/action/confirm_popup.jsx"];
28109
28540
  const {
28110
28541
  mode,
28111
28542
  confirmLabel,
@@ -28189,7 +28620,7 @@ const defaultBody = (message, {
28189
28620
  });
28190
28621
  };
28191
28622
 
28192
- installImportMetaCssBuild(import.meta);const css$Q = /* css */`
28623
+ installImportMetaCssBuild(import.meta);const css$R = /* css */`
28193
28624
  .action_error {
28194
28625
  margin-top: 0;
28195
28626
  margin-bottom: 20px;
@@ -28214,7 +28645,7 @@ const ActionRenderer = ({
28214
28645
  children,
28215
28646
  disabled
28216
28647
  }) => {
28217
- import.meta.css = [css$Q, "@jsenv/navi/src/action/action_renderer.jsx"];
28648
+ import.meta.css = [css$R, "@jsenv/navi/src/action/action_renderer.jsx"];
28218
28649
  if (action === undefined) {
28219
28650
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
28220
28651
  }
@@ -31540,12 +31971,30 @@ const useActionAsyncData = (action, {
31540
31971
  const runningState = action.runningStateSignal.peek();
31541
31972
  const [, setTick] = useState(0);
31542
31973
  useEffect(() => {
31543
- return action.runningStateSignal.subscribe(state => {
31974
+ const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
31544
31975
  if (state === RUNNING) {
31545
31976
  dismissedActionWeakSet.delete(action);
31546
31977
  }
31547
31978
  setTick(n => n + 1);
31548
31979
  });
31980
+ // The data does not come from this action's runs alone: dataSignal is a
31981
+ // computed over the resource store, so an other action writing that store
31982
+ // (a PUT upserting an item that a GET_MANY list already holds) changes the
31983
+ // data while this action stays COMPLETED. Subscribing here re-renders
31984
+ // through the same controlled path as the run state, instead of `.value`.
31985
+ let dataNotificationIsInitial = true;
31986
+ const unsubscribeFromData = action.dataSignal.subscribe(() => {
31987
+ if (dataNotificationIsInitial) {
31988
+ // subscribe() calls back synchronously with the current value
31989
+ dataNotificationIsInitial = false;
31990
+ return;
31991
+ }
31992
+ setTick(n => n + 1);
31993
+ });
31994
+ return () => {
31995
+ unsubscribeFromRunningState();
31996
+ unsubscribeFromData();
31997
+ };
31549
31998
  }, []);
31550
31999
  if (runningState === COMPLETED) {
31551
32000
  return [action.dataSignal.peek(), false, undefined];
@@ -35391,7 +35840,7 @@ const PhoneSvg = () => {
35391
35840
  };
35392
35841
 
35393
35842
  installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
35394
- const css$P = /* css */`
35843
+ const css$Q = /* css */`
35395
35844
  .navi_text_anchor {
35396
35845
  vertical-align: baseline;
35397
35846
  user-select: none;
@@ -35426,7 +35875,7 @@ const TextAnchor = ({
35426
35875
  textSize,
35427
35876
  lineLayout
35428
35877
  }) => {
35429
- import.meta.css = [css$P, "@jsenv/navi/src/text/text_anchor.jsx"];
35878
+ import.meta.css = [css$Q, "@jsenv/navi/src/text/text_anchor.jsx"];
35430
35879
  const anchorRef = useRef();
35431
35880
 
35432
35881
  // Plain useLayoutEffect would also fire while an ancestor dialog/popover
@@ -35541,7 +35990,7 @@ const computeTopOffset = ({
35541
35990
  };
35542
35991
  const charTopCanvas = document.createElement("canvas");
35543
35992
 
35544
- installImportMetaCssBuild(import.meta);const css$O = /* css */`
35993
+ installImportMetaCssBuild(import.meta);const css$P = /* css */`
35545
35994
  @layer navi {
35546
35995
  /* Ensure data attributes from box.jsx can win to update display */
35547
35996
  .navi_icon {
@@ -35699,7 +36148,7 @@ const Icon = ({
35699
36148
  fillLine,
35700
36149
  ...props
35701
36150
  }) => {
35702
- import.meta.css = [css$O, "@jsenv/navi/src/text/icon.jsx"];
36151
+ import.meta.css = [css$P, "@jsenv/navi/src/text/icon.jsx"];
35703
36152
  const innerChildren = href ? jsx("svg", {
35704
36153
  width: "100%",
35705
36154
  height: "100%",
@@ -35852,7 +36301,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
35852
36301
  });
35853
36302
  };
35854
36303
 
35855
- installImportMetaCssBuild(import.meta);const css$N = /* css */`
36304
+ installImportMetaCssBuild(import.meta);const css$O = /* css */`
35856
36305
  @layer navi {
35857
36306
  .navi_link {
35858
36307
  --link-border-radius: unset;
@@ -36294,7 +36743,7 @@ Object.assign(PSEUDO_CLASSES, {
36294
36743
  * @param {boolean} [props.readOnly]
36295
36744
  */
36296
36745
  const Link = props => {
36297
- import.meta.css = [css$N, "@jsenv/navi/src/nav/link/link.jsx"];
36746
+ import.meta.css = [css$O, "@jsenv/navi/src/nav/link/link.jsx"];
36298
36747
  if (props.route) {
36299
36748
  return jsx(LinkWithRoute, {
36300
36749
  ...props
@@ -36529,7 +36978,7 @@ installImportMetaCssBuild(import.meta);/**
36529
36978
  * TabList component with support for horizontal and vertical layouts
36530
36979
  * https://dribbble.com/search/tabs
36531
36980
  */
36532
- const css$M = /* css */`
36981
+ const css$N = /* css */`
36533
36982
  @layer navi {
36534
36983
  .navi_nav {
36535
36984
  --nav-border: none;
@@ -36704,7 +37153,7 @@ const Nav = ({
36704
37153
  // "before" or "after": which side the panel sits on, turning the nav into folder tabs
36705
37154
  ...props
36706
37155
  }) => {
36707
- import.meta.css = [css$M, "@jsenv/navi/src/nav/link/nav.jsx"];
37156
+ import.meta.css = [css$N, "@jsenv/navi/src/nav/link/nav.jsx"];
36708
37157
  children = toChildArray(children);
36709
37158
  return jsx(Box, {
36710
37159
  as: "nav",
@@ -37096,7 +37545,7 @@ installImportMetaCssBuild(import.meta);/**
37096
37545
  * Border width participates in layout (it is added to the tab and page
37097
37546
  * padding): a thick border grows the binder rather than eating into the text.
37098
37547
  */
37099
- const css$L = /* css */`
37548
+ const css$M = /* css */`
37100
37549
  @layer navi {
37101
37550
  .navi_binder {
37102
37551
  --binder-border-width: var(--navi-control-border-width);
@@ -37409,7 +37858,7 @@ const Binder = ({
37409
37858
  pagePadding,
37410
37859
  ...props
37411
37860
  }) => {
37412
- import.meta.css = [css$L, "@jsenv/navi/src/nav/binder/binder.jsx"];
37861
+ import.meta.css = [css$M, "@jsenv/navi/src/nav/binder/binder.jsx"];
37413
37862
  const items = toChildArray(children).map((child, index) => {
37414
37863
  const {
37415
37864
  value: itemValue,
@@ -37935,7 +38384,7 @@ installImportMetaCssBuild(import.meta);/**
37935
38384
  * into the size; a box-shadow draws the identical line and stays out of
37936
38385
  * layout.
37937
38386
  */
37938
- const css$K = /* css */`
38387
+ const css$L = /* css */`
37939
38388
  @layer navi {
37940
38389
  :root {
37941
38390
  --navi-fixed-bar-width: 56px;
@@ -38073,7 +38522,7 @@ const FixedBar = ({
38073
38522
  border = true,
38074
38523
  ...props
38075
38524
  }) => {
38076
- import.meta.css = [css$K, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
38525
+ import.meta.css = [css$L, "@jsenv/navi/src/layout/fixed_bar/fixed_bar.jsx"];
38077
38526
  const defaultRef = useRef();
38078
38527
  props.ref = props.ref || defaultRef;
38079
38528
  // Whichever of width/height crosses the edge the bar sits on is what the
@@ -38161,7 +38610,7 @@ const FixedBar = ({
38161
38610
  // Subpixel layout rounds rectangles up on boxes that fit exactly.
38162
38611
  const OVERFLOW_TOLERANCE = 1;
38163
38612
 
38164
- const css$J = /* css */ `
38613
+ const css$K = /* css */ `
38165
38614
  [data-navi-overflow-x] {
38166
38615
  outline: 2px dashed #e74c3c;
38167
38616
  outline-offset: -2px;
@@ -38185,7 +38634,7 @@ const detectHorizontalOverflow = ({
38185
38634
  let styleEl = null;
38186
38635
  if (highlight) {
38187
38636
  styleEl = document.createElement("style");
38188
- styleEl.textContent = css$J;
38637
+ styleEl.textContent = css$K;
38189
38638
  document.head.appendChild(styleEl);
38190
38639
  }
38191
38640
 
@@ -38341,7 +38790,7 @@ const useFocusGroup = (
38341
38790
 
38342
38791
  installImportMetaCssBuild(import.meta);const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
38343
38792
  const downArrowPath = "M480-280L160-600l80-80 240 240 240-240 80 80-320 320z";
38344
- const css$I = /* css */`
38793
+ const css$J = /* css */`
38345
38794
  .navi_summary_marker {
38346
38795
  width: 1em;
38347
38796
  height: 1em;
@@ -38426,7 +38875,7 @@ const SummaryMarker = ({
38426
38875
  open,
38427
38876
  loading
38428
38877
  }) => {
38429
- import.meta.css = [css$I, "@jsenv/navi/src/control/details/summary_marker.jsx"];
38878
+ import.meta.css = [css$J, "@jsenv/navi/src/control/details/summary_marker.jsx"];
38430
38879
  const showLoading = useDebounceTrue(loading, 300);
38431
38880
  const mountedRef = useRef(false);
38432
38881
  const prevOpenRef = useRef(open);
@@ -38480,7 +38929,7 @@ const SummaryMarker = ({
38480
38929
  });
38481
38930
  };
38482
38931
 
38483
- installImportMetaCssBuild(import.meta);const css$H = /* css */`
38932
+ installImportMetaCssBuild(import.meta);const css$I = /* css */`
38484
38933
  .navi_details {
38485
38934
  position: relative;
38486
38935
  z-index: 1;
@@ -38526,7 +38975,7 @@ const Details = props => {
38526
38975
  return details;
38527
38976
  };
38528
38977
  const DetailsField = props => {
38529
- import.meta.css = [css$H, "@jsenv/navi/src/control/details/details.jsx"];
38978
+ import.meta.css = [css$I, "@jsenv/navi/src/control/details/details.jsx"];
38530
38979
  const {
38531
38980
  ref,
38532
38981
  persists,
@@ -38782,7 +39231,7 @@ const ControlGroup = props => {
38782
39231
  };
38783
39232
  const CONTROL_GROUP_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
38784
39233
 
38785
- installImportMetaCssBuild(import.meta);const css$G = /* css */`
39234
+ installImportMetaCssBuild(import.meta);const css$H = /* css */`
38786
39235
  @layer navi {
38787
39236
  .navi_checkbox {
38788
39237
  --switch-margin: 0; /* Useful to reserve space for outline */
@@ -38860,7 +39309,7 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
38860
39309
  }
38861
39310
  `;
38862
39311
  const SwitchUI = () => {
38863
- import.meta.css = [css$G, "@jsenv/navi/src/control/input/switch_ui.jsx"];
39312
+ import.meta.css = [css$H, "@jsenv/navi/src/control/input/switch_ui.jsx"];
38864
39313
  return jsx(Box, {
38865
39314
  className: "navi_switch",
38866
39315
  as: "svg",
@@ -38902,7 +39351,7 @@ const useCheckableProps = (props, options) => {
38902
39351
  return result;
38903
39352
  };
38904
39353
 
38905
- installImportMetaCssBuild(import.meta);const css$F = /* css */`
39354
+ installImportMetaCssBuild(import.meta);const css$G = /* css */`
38906
39355
  @layer navi {
38907
39356
  .navi_checkbox {
38908
39357
  --border-radius: var(--navi-checkbox-border-radius);
@@ -39229,7 +39678,7 @@ const InputCheckboxHeadless = props => {
39229
39678
  });
39230
39679
  };
39231
39680
  const InputCheckboxFieldInterface = props => {
39232
- import.meta.css = [css$F, "@jsenv/navi/src/control/input/input_checkbox.jsx"];
39681
+ import.meta.css = [css$G, "@jsenv/navi/src/control/input/input_checkbox.jsx"];
39233
39682
  const [checkboxRootProps, checkboxHostProps] = useCheckableProps(props);
39234
39683
  const {
39235
39684
  icon,
@@ -39351,7 +39800,7 @@ const CheckboxButtonStyleCSSVars = {
39351
39800
  const CheckboxPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":checked", ":-navi-loading"];
39352
39801
  const CheckboxPseudoElements = ["::-navi-loader", "::-navi-checkmark"];
39353
39802
 
39354
- installImportMetaCssBuild(import.meta);const css$E = /* css */`
39803
+ installImportMetaCssBuild(import.meta);const css$F = /* css */`
39355
39804
  @layer navi {
39356
39805
  .navi_label {
39357
39806
  --label-required-indicator-color: var(--navi-color-danger, #b42318);
@@ -39431,7 +39880,7 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
39431
39880
  * </Field>
39432
39881
  */
39433
39882
  const Field = props => {
39434
- import.meta.css = [css$E, "@jsenv/navi/src/control/field.jsx"];
39883
+ import.meta.css = [css$F, "@jsenv/navi/src/control/field.jsx"];
39435
39884
  const refDefault = useRef();
39436
39885
  props.ref = props.ref || refDefault;
39437
39886
  const {
@@ -39466,7 +39915,7 @@ const FieldCSSVars = {
39466
39915
  spacingWithControl: "--spacing-with-control"
39467
39916
  };
39468
39917
  const FieldAsContainer = props => {
39469
- import.meta.css = [css$E, "@jsenv/navi/src/control/field.jsx"];
39918
+ import.meta.css = [css$F, "@jsenv/navi/src/control/field.jsx"];
39470
39919
  const {
39471
39920
  children
39472
39921
  } = props;
@@ -39498,7 +39947,7 @@ const FieldAsContainer = props => {
39498
39947
  };
39499
39948
  const FIELD_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
39500
39949
  const Label = props => {
39501
- import.meta.css = [css$E, "@jsenv/navi/src/control/field.jsx"];
39950
+ import.meta.css = [css$F, "@jsenv/navi/src/control/field.jsx"];
39502
39951
  const {
39503
39952
  children,
39504
39953
  // Marks the label when its control is required. Takes what to show, or
@@ -39521,6 +39970,25 @@ const Label = props => {
39521
39970
  if (!Object.hasOwn(props, "htmlFor") && controlId) {
39522
39971
  props.htmlFor = controlId;
39523
39972
  }
39973
+ // A label pointing at its control by id is not inside it and does not contain
39974
+ // it, so nothing in the DOM links the two — the control publishes its state
39975
+ // under that id and this is where the label picks it up (see
39976
+ // control_label_state.js). A label that wraps its control instead is told
39977
+ // through the navi_control_state event below.
39978
+ const {
39979
+ htmlFor
39980
+ } = props;
39981
+ useLayoutEffect(() => {
39982
+ if (!htmlFor) {
39983
+ return undefined;
39984
+ }
39985
+ return subscribeToControlState(htmlFor, controlState => {
39986
+ setConnected(Boolean(controlState));
39987
+ setDisabled(Boolean(controlState?.disabled));
39988
+ setReadOnly(Boolean(controlState?.readOnly));
39989
+ setRequired(Boolean(controlState?.required));
39990
+ });
39991
+ }, [htmlFor]);
39524
39992
  const [messageProps, remainingProps] = extractMessageAndRemainingProps({
39525
39993
  ...props,
39526
39994
  requiredIndicator: undefined
@@ -39641,7 +40109,7 @@ const InputSlot = ({
39641
40109
  });
39642
40110
  };
39643
40111
 
39644
- installImportMetaCssBuild(import.meta);const css$D = /* css */`
40112
+ installImportMetaCssBuild(import.meta);const css$E = /* css */`
39645
40113
  @layer navi {
39646
40114
  .navi_radio {
39647
40115
  --margin: 3px 3px 3px 5px;
@@ -40004,7 +40472,7 @@ const InputRadioHeadless = props => {
40004
40472
  };
40005
40473
  const APPEARANCE_SET = new Set(["icon", "button", "radio"]);
40006
40474
  const InputRadioFieldInterface = props => {
40007
- import.meta.css = [css$D, "@jsenv/navi/src/control/input/input_radio.jsx"];
40475
+ import.meta.css = [css$E, "@jsenv/navi/src/control/input/input_radio.jsx"];
40008
40476
  const [radioRootProps, radioHostProps] = useCheckableProps(props);
40009
40477
  const {
40010
40478
  icon,
@@ -40150,7 +40618,7 @@ const RadioButtonStyleCSSVars = {
40150
40618
  const RadioPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":checked", ":-navi-loading"];
40151
40619
  const RadioPseudoElements = ["::-navi-loader", "::-navi-radiomark"];
40152
40620
 
40153
- installImportMetaCssBuild(import.meta);const css$C = /* css */`
40621
+ installImportMetaCssBuild(import.meta);const css$D = /* css */`
40154
40622
  @layer navi {
40155
40623
  .navi_input_range {
40156
40624
  --border-radius: 6px;
@@ -40405,7 +40873,7 @@ const InputRange = props => {
40405
40873
  });
40406
40874
  };
40407
40875
  const InputRangeFieldInterface = props => {
40408
- import.meta.css = [css$C, "@jsenv/navi/src/control/input/input_range.jsx"];
40876
+ import.meta.css = [css$D, "@jsenv/navi/src/control/input/input_range.jsx"];
40409
40877
  const {
40410
40878
  ref
40411
40879
  } = props;
@@ -42332,7 +42800,7 @@ installImportMetaCssBuild(import.meta);/**
42332
42800
  * This means an editable thing MUST have a parent with position relative that wraps the content and the eventual editable input
42333
42801
  *
42334
42802
  */
42335
- const css$B = /* css */`
42803
+ const css$C = /* css */`
42336
42804
  .navi_editable_wrapper {
42337
42805
  --inset-top: 0px;
42338
42806
  --inset-right: 0px;
@@ -42381,7 +42849,7 @@ const useEditionController = () => {
42381
42849
  };
42382
42850
  };
42383
42851
  const Editable = props => {
42384
- import.meta.css = [css$B, "@jsenv/navi/src/control/edition/editable.jsx"];
42852
+ import.meta.css = [css$C, "@jsenv/navi/src/control/edition/editable.jsx"];
42385
42853
  let {
42386
42854
  children,
42387
42855
  action,
@@ -42611,6 +43079,7 @@ const useFormGroup = props => {
42611
43079
  // against the state of the previous frame.
42612
43080
  uiStateController.shouldRequestAction = value => Boolean(props.canSendWhileUnchanged) || !compareTwoJsValues(withoutEmptyFields(value), uiStateController.sentUIState);
42613
43081
  useFirstUIStateAsSent(uiStateController);
43082
+ useUnregisteredControlWarning(props.ref);
42614
43083
  const {
42615
43084
  basePseudoState,
42616
43085
  children
@@ -42759,6 +43228,15 @@ const useFirstUIStateAsSent = uiStateController => {
42759
43228
  uiStateController.sentUIState = readHeldUIState(uiStateController);
42760
43229
  }, [uiStateController]);
42761
43230
  };
43231
+ const useUnregisteredControlWarning = ref => {
43232
+ // No dependency array: fields appear and disappear as the form re-renders,
43233
+ // and a field rendered later is exactly the one worth catching.
43234
+ useLayoutEffect(() => {
43235
+ {
43236
+ return;
43237
+ }
43238
+ });
43239
+ };
42762
43240
  const FormPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
42763
43241
 
42764
43242
  // https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Constraint_validation
@@ -42795,7 +43273,7 @@ HTMLFormElement.prototype.requestSubmit = function (submitter) {
42795
43273
  // form.dispatchEvent(customEvent);
42796
43274
  // };
42797
43275
 
42798
- installImportMetaCssBuild(import.meta);const css$A = /* css */`
43276
+ installImportMetaCssBuild(import.meta);const css$B = /* css */`
42799
43277
  .navi_group {
42800
43278
  --group-border-width: 1px;
42801
43279
 
@@ -42891,7 +43369,7 @@ const Group = ({
42891
43369
  vertical = row,
42892
43370
  ...props
42893
43371
  }) => {
42894
- import.meta.css = [css$A, "@jsenv/navi/src/control/group.jsx"];
43372
+ import.meta.css = [css$B, "@jsenv/navi/src/control/group.jsx"];
42895
43373
  return jsx(Box, {
42896
43374
  baseClassName: "navi_group",
42897
43375
  "data-vertical": vertical ? "" : undefined,
@@ -43018,7 +43496,7 @@ installImportMetaCssBuild(import.meta);/**
43018
43496
  * pair. One popup holding slides of its own contents has no such problem — and
43019
43497
  * it is the same component in the document, in a dialog or in a popover.
43020
43498
  */
43021
- const css$z = /* css */`
43499
+ const css$A = /* css */`
43022
43500
  /* Every slide in the same grid cell: the box then measures itself on the
43023
43501
  LARGEST of them, in both directions, without anything being measured by
43024
43502
  hand — which is also why nothing here resizes as the slides change. Each
@@ -43328,7 +43806,7 @@ const SlideContainer = ({
43328
43806
  children,
43329
43807
  ...rest
43330
43808
  }) => {
43331
- import.meta.css = [css$z, "@jsenv/navi/src/layout/slide_container.jsx"];
43809
+ import.meta.css = [css$A, "@jsenv/navi/src/layout/slide_container.jsx"];
43332
43810
  const debugFocus = useDebugFocus();
43333
43811
  const trackRef = useRef();
43334
43812
  // The box itself: it is what takes the keyboard when what is on screen holds
@@ -45192,7 +45670,7 @@ installImportMetaCssBuild(import.meta);/**
45192
45670
  * pass through untouched via `...rest` to whichever of Popover/Dialog
45193
45671
  * actually renders.
45194
45672
  */
45195
- const css$y = /* css */`
45673
+ const css$z = /* css */`
45196
45674
  @layer navi {
45197
45675
  .navi_popup {
45198
45676
  --popup-border-radius: var(--navi-popup-border-radius);
@@ -45283,10 +45761,15 @@ const css$y = /* css */`
45283
45761
  * @param {string} [props.className] - Merged with the shared
45284
45762
  * `"navi_popup"` class (see this file's own CSS) rather than replacing
45285
45763
  * it.
45764
+ * @param {boolean} [props.mountWhenClosed] - Builds `children` right away
45765
+ * instead of waiting for the first open (see popup_content_mount.js). For
45766
+ * content something depends on while the popup is still closed: a value read
45767
+ * off it, fields a surrounding form collects on submit, a size measured from
45768
+ * outside.
45286
45769
  * @param {import("ignore:preact").ComponentChildren} props.children
45287
45770
  */
45288
45771
  const Popup = props => {
45289
- import.meta.css = [css$y, "@jsenv/navi/src/layout/popup.jsx"];
45772
+ import.meta.css = [css$z, "@jsenv/navi/src/layout/popup.jsx"];
45290
45773
  const {
45291
45774
  mode: modeProp,
45292
45775
  maxWidth,
@@ -45429,7 +45912,7 @@ const commitSubtree = (controller, e) => {
45429
45912
  }
45430
45913
  };
45431
45914
 
45432
- installImportMetaCssBuild(import.meta);const css$x = /* css */`
45915
+ installImportMetaCssBuild(import.meta);const css$y = /* css */`
45433
45916
  .navi_picker {
45434
45917
  /* Sizing ceilings (maxmax), background, box-shadow, outline, padding,
45435
45918
  overflow... are already handled correctly by Popup/Popover/Dialog
@@ -45541,7 +46024,7 @@ installImportMetaCssBuild(import.meta);const css$x = /* css */`
45541
46024
  }
45542
46025
  `;
45543
46026
  const PickerCustomResolver = props => {
45544
- import.meta.css = [css$x, "@jsenv/navi/src/control/picker/picker_custom.jsx"];
46027
+ import.meta.css = [css$y, "@jsenv/navi/src/control/picker/picker_custom.jsx"];
45545
46028
  if (props.children === undefined) {
45546
46029
  return jsx(PickerNative, {
45547
46030
  ...props
@@ -45807,6 +46290,13 @@ const PickerCustom = props => {
45807
46290
  Object.assign(popupProps, {
45808
46291
  anchor: props.ref,
45809
46292
  openController,
46293
+ // A picker whose value was never given to it reads it off the control in
46294
+ // its popup (see useUIFacadeStateController): the trigger shows what the
46295
+ // list inside says is selected, so that list has to exist before anyone
46296
+ // opens anything. Told a value — even an empty one — the picker owns it
46297
+ // and pushes it down instead, leaving the popup free to build its
46298
+ // content only when it is first opened (see popup_content_mount.js).
46299
+ mountWhenClosed: !Object.hasOwn(props, "value") && !Object.hasOwn(props, "defaultValue"),
45810
46300
  // Not on pickerProps (the trigger): commands.js's own
45811
46301
  // resolveClosestExpandable() does `el.closest("[aria-expanded]")` to
45812
46302
  // find where to dispatch navi_request_open/navi_request_close — and
@@ -46186,7 +46676,7 @@ const LoadingIndicator = ({
46186
46676
  });
46187
46677
  };
46188
46678
 
46189
- installImportMetaCssBuild(import.meta);const css$w = /* css */`
46679
+ installImportMetaCssBuild(import.meta);const css$x = /* css */`
46190
46680
  @layer navi {
46191
46681
  .navi_separator {
46192
46682
  --size: 1px;
@@ -46264,7 +46754,7 @@ const Separator = ({
46264
46754
  style,
46265
46755
  ...props
46266
46756
  }) => {
46267
- import.meta.css = [css$w, "@jsenv/navi/src/layout/separator.jsx"];
46757
+ import.meta.css = [css$x, "@jsenv/navi/src/layout/separator.jsx"];
46268
46758
  return jsx(Box, {
46269
46759
  as: vertical ? "span" : "hr",
46270
46760
  ...props,
@@ -46757,7 +47247,7 @@ const ListItemFooter = props => {
46757
47247
  });
46758
47248
  };
46759
47249
 
46760
- installImportMetaCssBuild(import.meta);const css$v = /* css */`
47250
+ installImportMetaCssBuild(import.meta);const css$w = /* css */`
46761
47251
  @layer navi {
46762
47252
  .navi_list_container[navi-selectable] {
46763
47253
  /* Focus outline */
@@ -46961,7 +47451,7 @@ const ListSelectableResolver = props => {
46961
47451
  };
46962
47452
  const ListSelectable = props => {
46963
47453
  const Next = useNextResolver();
46964
- import.meta.css = [css$v, "@jsenv/navi/src/control/list/list_selectable.jsx"];
47454
+ import.meta.css = [css$w, "@jsenv/navi/src/control/list/list_selectable.jsx"];
46965
47455
  // we allow ourselves to auto-generate a name
46966
47456
  const defaultName = useId();
46967
47457
  props.name = props.name || `listbox_${defaultName}`;
@@ -47556,7 +48046,7 @@ const ListVirtualContext = createContext(null);
47556
48046
  // that returning a component of one's own — instead of a bare <List.Item> —
47557
48047
  // works the same way.
47558
48048
  const ListRowContext = createContext(null);
47559
- const css$u = /* css */`
48049
+ const css$v = /* css */`
47560
48050
  @layer navi {
47561
48051
  .navi_list_container {
47562
48052
  --list-outline-width: 1px;
@@ -48009,13 +48499,10 @@ const css$u = /* css */`
48009
48499
  font-size: 1em;
48010
48500
  line-height: 1.4;
48011
48501
  }
48012
- /* A control that IS the row — a direct child of the item, so it spans it
48013
- must keep its loading outline within its own box: the scroll container is
48014
- overflow:auto, and the couple pixels the outline normally draws outside
48015
- the control are enough to make it scrollable, so a scrollbar would appear
48016
- and disappear as things load. Targeted on the outline itself rather than
48017
- inherited from the item, so a control nested deeper (which has room around
48018
- it, and does not reach the edges) keeps the outline it asked for. */
48502
+ /* Same rule as [data-scrollable] in box.jsx, said again for this scroller:
48503
+ what an item holds IS against the edge of the scroll container — the list
48504
+ element between the two is markup, not spacing so its loading outline
48505
+ stays inside its own box rather than raising a scrollbar. */
48019
48506
  .navi_list_item > .navi_loading_outline_wrapper,
48020
48507
  .navi_list_item > * > .navi_loading_outline_wrapper,
48021
48508
  .navi_list_item_header > * > .navi_loading_outline_wrapper,
@@ -48084,7 +48571,7 @@ const css$u = /* css */`
48084
48571
  }
48085
48572
  `;
48086
48573
  const ListUI = props => {
48087
- import.meta.css = [css$u, "@jsenv/navi/src/control/list/list.jsx"];
48574
+ import.meta.css = [css$v, "@jsenv/navi/src/control/list/list.jsx"];
48088
48575
  const {
48089
48576
  ref,
48090
48577
  renderBudget: renderBudgetProp = RENDER_BUDGET_DEFAULT,
@@ -51187,7 +51674,7 @@ const PickerPresetResolver = props => {
51187
51674
  });
51188
51675
  };
51189
51676
 
51190
- installImportMetaCssBuild(import.meta);const css$t = /* css */`
51677
+ installImportMetaCssBuild(import.meta);const css$u = /* css */`
51191
51678
  @layer navi {
51192
51679
  }
51193
51680
  .navi_badge {
@@ -51299,7 +51786,7 @@ const Badge = ({
51299
51786
  className,
51300
51787
  ...props
51301
51788
  }) => {
51302
- import.meta.css = [css$t, "@jsenv/navi/src/text/badge.jsx"];
51789
+ import.meta.css = [css$u, "@jsenv/navi/src/text/badge.jsx"];
51303
51790
  const defaultRef = useRef();
51304
51791
  props.ref = props.ref || defaultRef;
51305
51792
  const {
@@ -51351,7 +51838,7 @@ const BadgeButton = props => {
51351
51838
  };
51352
51839
  Badge.Button = BadgeButton;
51353
51840
 
51354
- installImportMetaCssBuild(import.meta);const css$s = /* css */`
51841
+ installImportMetaCssBuild(import.meta);const css$t = /* css */`
51355
51842
  @layer navi {
51356
51843
  }
51357
51844
  .navi_badge_list {
@@ -51376,7 +51863,7 @@ const BadgeList = ({
51376
51863
  max,
51377
51864
  ...props
51378
51865
  }) => {
51379
- import.meta.css = [css$s, "@jsenv/navi/src/text/badge_list.jsx"];
51866
+ import.meta.css = [css$t, "@jsenv/navi/src/text/badge_list.jsx"];
51380
51867
  const measureRef = useRef();
51381
51868
  const visibleRef = useRef();
51382
51869
  useLayoutEffect(() => {
@@ -51451,7 +51938,7 @@ const BadgeList = ({
51451
51938
  });
51452
51939
  };
51453
51940
 
51454
- installImportMetaCssBuild(import.meta);const css$r = /* css */`
51941
+ installImportMetaCssBuild(import.meta);const css$s = /* css */`
51455
51942
  .navi_color {
51456
51943
  display: block;
51457
51944
  aspect-ratio: 1/1;
@@ -51482,7 +51969,7 @@ const Color = ({
51482
51969
  children,
51483
51970
  ...rest
51484
51971
  }) => {
51485
- import.meta.css = [css$r, "@jsenv/navi/src/text/color.jsx"];
51972
+ import.meta.css = [css$s, "@jsenv/navi/src/text/color.jsx"];
51486
51973
  const color = children || undefined;
51487
51974
  return jsx(Box, {
51488
51975
  as: "span",
@@ -51937,7 +52424,7 @@ const PickerFileUI = () => {
51937
52424
  return String(value);
51938
52425
  };
51939
52426
 
51940
- installImportMetaCssBuild(import.meta);const css$q = /* css */`
52427
+ installImportMetaCssBuild(import.meta);const css$r = /* css */`
51941
52428
  @layer navi {
51942
52429
  .navi_picker {
51943
52430
  --picker-border-radius: var(--navi-control-border-radius);
@@ -52263,7 +52750,7 @@ installImportMetaCssBuild(import.meta);const css$q = /* css */`
52263
52750
  }
52264
52751
  `;
52265
52752
  const PickerButton = props => {
52266
- import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker.jsx"];
52753
+ import.meta.css = [css$r, "@jsenv/navi/src/control/picker/picker.jsx"];
52267
52754
  if (typeof props.maxLines === "string") {
52268
52755
  props.maxLines = parseInt(props.maxLines);
52269
52756
  }
@@ -52726,7 +53213,7 @@ installImportMetaCssBuild(import.meta);/**
52726
53213
  * refuse it on purpose, which is what keeps the focus where the travel happens
52727
53214
  * instead of moving it into a slide that is about to leave.
52728
53215
  */
52729
- const css$p = /* css */`
53216
+ const css$q = /* css */`
52730
53217
  @layer navi {
52731
53218
  .navi_picker_spin {
52732
53219
  /* A picker one steps through is still a picker: what themes every picker
@@ -53072,7 +53559,7 @@ const Spin = ({
53072
53559
  nextLabel,
53073
53560
  ...rest
53074
53561
  }) => {
53075
- import.meta.css = [css$p, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
53562
+ import.meta.css = [css$q, "@jsenv/navi/src/control/picker/picker_spin.jsx"];
53076
53563
  const id = useId();
53077
53564
  const containerId = `${id}_values`;
53078
53565
  const controlId = `${id}_control`;
@@ -53683,7 +54170,7 @@ const addDays = (day, count) => {
53683
54170
  };
53684
54171
 
53685
54172
  installImportMetaCssBuild(import.meta);// TOFIX: select in data then reset, it reset to red/blue instead of red/blue/green
53686
- const css$o = /* css */`
54173
+ const css$p = /* css */`
53687
54174
  .navi_checkbox_group {
53688
54175
  border-style: solid;
53689
54176
 
@@ -53703,7 +54190,7 @@ const CheckboxGroup = props => {
53703
54190
  return checkboxGroup;
53704
54191
  };
53705
54192
  const CheckboxGroupInterface = props => {
53706
- import.meta.css = [css$o, "@jsenv/navi/src/control/input/checkbox_group.jsx"];
54193
+ import.meta.css = [css$p, "@jsenv/navi/src/control/input/checkbox_group.jsx"];
53707
54194
  const {
53708
54195
  ref
53709
54196
  } = props;
@@ -53752,7 +54239,7 @@ installImportMetaCssBuild(import.meta);/**
53752
54239
  * shared sheet is registered here too — a page may render a Textarea without
53753
54240
  * any Input.
53754
54241
  */
53755
- const css$n = /* css */`
54242
+ const css$o = /* css */`
53756
54243
  .navi_input.navi_textarea {
53757
54244
  .navi_control_input {
53758
54245
  min-height: calc(var(--textarea-min-rows, 1.5) * 1lh);
@@ -53769,6 +54256,18 @@ const css$n = /* css */`
53769
54256
  /* The control grows itself; resizable below hands the handle back. */
53770
54257
  resize: none;
53771
54258
  overflow: auto;
54259
+ /* A placeholder must be readable in full before anything is typed: a
54260
+ field that opens already scrolled reads as a field that already has
54261
+ text in it. Its wrapped height is measured (see usePlaceholderHeight)
54262
+ because it only exists once laid out, and it only raises the floor
54263
+ while the placeholder is what is being shown — what is typed sizes the
54264
+ box on its own. */
54265
+ &:placeholder-shown {
54266
+ min-height: max(
54267
+ calc(var(--textarea-min-rows, 1.5) * 1lh),
54268
+ var(--x-textarea-placeholder-height, 0px)
54269
+ );
54270
+ }
53772
54271
  }
53773
54272
  &[data-resizable] .navi_control_input {
53774
54273
  height: calc(var(--textarea-min-rows, 1.5) * 1lh);
@@ -53806,7 +54305,9 @@ const css$n = /* css */`
53806
54305
  * @param {number} [maxRows] Lines after which the control stops growing and
53807
54306
  * scrolls instead. Without it the control grows with its content.
53808
54307
  * @param {boolean} [resizable] Give the browser's vertical resize handle back.
53809
- * A manual resize takes over from the automatic growth.
54308
+ * An exchange, not an addition: the hand takes over from the automatic
54309
+ * growth, so the control stops following what is typed and stays at the
54310
+ * height it was last dragged to (starting at `minRows`).
53810
54311
  * @param {number} [maxLength] The character limit, validated at submit. Pair
53811
54312
  * with `maxLengthGuard` to block typing past it, and render a
53812
54313
  * TextareaCharCount to show it.
@@ -53825,9 +54326,10 @@ const Textarea = ({
53825
54326
  width = "35ch",
53826
54327
  ...props
53827
54328
  }) => {
53828
- import.meta.css = [inputCss + css$n, "@jsenv/navi/src/control/input/textarea.jsx"];
54329
+ import.meta.css = [inputCss + css$o, "@jsenv/navi/src/control/input/textarea.jsx"];
53829
54330
  const defaultRef = useRef(null);
53830
54331
  props.ref = props.ref || defaultRef;
54332
+ usePlaceholderHeight(props.ref, props.placeholder);
53831
54333
  const [rootProps, hostProps, childrenWrapperProps] = useControlProps(props, {
53832
54334
  controlType: "input"
53833
54335
  });
@@ -53898,7 +54400,7 @@ const TextareaCharCount = ({
53898
54400
  maxLength,
53899
54401
  ...rest
53900
54402
  }) => {
53901
- import.meta.css = [css$n, "@jsenv/navi/src/control/input/textarea.jsx"];
54403
+ import.meta.css = [css$o, "@jsenv/navi/src/control/input/textarea.jsx"];
53902
54404
  const resolvedValue = signal ? signal.value : value;
53903
54405
  const length = typeof resolvedValue === "string" ? resolvedValue.length : 0;
53904
54406
  return jsx(Box, {
@@ -53908,6 +54410,61 @@ const TextareaCharCount = ({
53908
54410
  children: maxLength === undefined ? length : `${length}/${maxLength}`
53909
54411
  });
53910
54412
  };
54413
+
54414
+ // `field-sizing: content` sizes the box from the value, and an empty field has
54415
+ // none — the placeholder is text the browser refuses to make room for. So the
54416
+ // height it wraps to is measured and published as --x-textarea-placeholder-height
54417
+ // for the CSS above to use as a floor.
54418
+ const usePlaceholderHeight = (ref, placeholder) => {
54419
+ useLayoutEffect(() => {
54420
+ const textareaEl = ref.current;
54421
+ if (!placeholder) {
54422
+ textareaEl.style.removeProperty("--x-textarea-placeholder-height");
54423
+ return null;
54424
+ }
54425
+ let widthMeasured;
54426
+ const measure = () => {
54427
+ // What is typed sizes the box itself; the placeholder is not displayed
54428
+ // then, and scrollHeight would report the value's height instead.
54429
+ if (textareaEl.value !== "") {
54430
+ return;
54431
+ }
54432
+ const {
54433
+ paddingTop,
54434
+ paddingBottom
54435
+ } = getComputedStyle(textareaEl);
54436
+ // Cleared before reading: scrollHeight can never report less than the
54437
+ // height already applied, so measuring on top of a previous measure could
54438
+ // only ever grow the box, never let it shrink back on a wider viewport.
54439
+ textareaEl.style.setProperty("--x-textarea-placeholder-height", "0px");
54440
+ const contentHeight = textareaEl.scrollHeight - parseFloat(paddingTop) - parseFloat(paddingBottom);
54441
+ widthMeasured = textareaEl.clientWidth;
54442
+ textareaEl.style.setProperty("--x-textarea-placeholder-height", `${contentHeight}px`);
54443
+ };
54444
+ measure();
54445
+ // The placeholder wraps against the available width, so a new width is a
54446
+ // new number of lines. Height changes are ignored: this measure is what
54447
+ // causes them, and reacting to them would be reacting to ourselves.
54448
+ const resizeObserver = new ResizeObserver(() => {
54449
+ if (textareaEl.clientWidth !== widthMeasured) {
54450
+ measure();
54451
+ }
54452
+ });
54453
+ resizeObserver.observe(textareaEl);
54454
+ // The width may have changed while the field held a value, when measuring
54455
+ // was impossible — emptying it is when the placeholder comes back.
54456
+ const onInput = () => {
54457
+ if (textareaEl.value === "") {
54458
+ measure();
54459
+ }
54460
+ };
54461
+ textareaEl.addEventListener("input", onInput);
54462
+ return () => {
54463
+ resizeObserver.disconnect();
54464
+ textareaEl.removeEventListener("input", onInput);
54465
+ };
54466
+ }, [placeholder]);
54467
+ };
53911
54468
  const RealTextarea = ({
53912
54469
  maxLength,
53913
54470
  ...domProps
@@ -54233,7 +54790,7 @@ const isTextInputElement = (el) => {
54233
54790
  );
54234
54791
  };
54235
54792
 
54236
- installImportMetaCssBuild(import.meta);const css$m = /* css */`
54793
+ installImportMetaCssBuild(import.meta);const css$n = /* css */`
54237
54794
  .navi_input_duration {
54238
54795
  --duration-separator-spacing: 4px;
54239
54796
  --loader-color: var(--navi-loader-color);
@@ -54300,7 +54857,7 @@ installImportMetaCssBuild(import.meta);const css$m = /* css */`
54300
54857
  * "auto" aligns each field toward its neighbouring separator (first→right, last→left, middle/solo→center).
54301
54858
  */
54302
54859
  const InputDuration = props => {
54303
- import.meta.css = [css$m, "@jsenv/navi/src/control/input/input_duration.jsx"];
54860
+ import.meta.css = [css$n, "@jsenv/navi/src/control/input/input_duration.jsx"];
54304
54861
  const defaultRef = useRef();
54305
54862
  props.ref = props.ref || defaultRef;
54306
54863
  props.max = props.max || "23h59";
@@ -54802,7 +55359,7 @@ const InputDurationPart = ({
54802
55359
  });
54803
55360
  };
54804
55361
 
54805
- installImportMetaCssBuild(import.meta);const css$l = /* css */`
55362
+ installImportMetaCssBuild(import.meta);const css$m = /* css */`
54806
55363
  .navi_radio_group {
54807
55364
  border-style: solid;
54808
55365
 
@@ -54822,7 +55379,7 @@ const RadioGroup = props => {
54822
55379
  return radioGroup;
54823
55380
  };
54824
55381
  const RadioGroupInterface = props => {
54825
- import.meta.css = [css$l, "@jsenv/navi/src/control/input/radio_group.jsx"];
55382
+ import.meta.css = [css$m, "@jsenv/navi/src/control/input/radio_group.jsx"];
54826
55383
  const {
54827
55384
  ref
54828
55385
  } = props;
@@ -54851,6 +55408,139 @@ const RadioGroupInterface = props => {
54851
55408
  });
54852
55409
  };
54853
55410
 
55411
+ installImportMetaCssBuild(import.meta);/**
55412
+ * A native `<select>` that is a navi control: its value enters the state of the
55413
+ * `<Form>` around it, and it takes `signal`, `uiAction`, `action`, `command`,
55414
+ * `value`/`defaultValue`, `readOnly`, `disabled`, `required` like every other
55415
+ * control.
55416
+ *
55417
+ * Native on purpose, and not a Picker: on a phone a `<select>` opens the
55418
+ * system's own full-screen list — the thing the thumb handles best and the user
55419
+ * already knows — with no popup, no positioning and no focus trap to get wrong.
55420
+ * That is the right control for a short closed list (a gender, an age bracket,
55421
+ * "who sees this"). A long, searchable list with rich content in its options is
55422
+ * Picker's problem, not this one.
55423
+ *
55424
+ * The options are the children, written as HTML: an `<optgroup>`, a `disabled`
55425
+ * option, an `<hr>` between two groups need no support from this component.
55426
+ *
55427
+ * Two things the component absorbs, both traps met before:
55428
+ * - a native `<select>` ignores `defaultValue` and reads `selected` off its
55429
+ * options instead; here `value`/`defaultValue` mean what they mean everywhere
55430
+ * else in navi, and the element is told what to show.
55431
+ * - a native `<select>` has no `readonly`; `readOnly` here refuses the
55432
+ * interaction (the list does not open, a change from the keyboard is put
55433
+ * back) and says so with `aria-readonly`.
55434
+ *
55435
+ * Styled as a `.navi_input` box so a select and an input sitting next to each
55436
+ * other are the same box. `appearance: none` only changes how the closed
55437
+ * control is drawn — the list it opens stays the platform's own, which is the
55438
+ * whole point of using a select.
55439
+ */
55440
+ const css$l = /* css */`
55441
+ .navi_input.navi_select {
55442
+ .navi_control_input {
55443
+ /* Room for the chevron, which sits over the padding rather than beside
55444
+ the control — anything beside it would be a click that misses. */
55445
+ padding-right: calc(var(--x-padding-right) + 1em);
55446
+ /* A form control keeps a line of its own whatever the page is written in,
55447
+ and lh units elsewhere in the box are resolved against a real number. */
55448
+ line-height: normal;
55449
+ /* The closed control is drawn by us so it matches the other fields; the
55450
+ list it opens is untouched and stays the system's. */
55451
+ appearance: none;
55452
+ cursor: pointer;
55453
+ }
55454
+ &[data-readonly] .navi_control_input,
55455
+ &[data-disabled] .navi_control_input {
55456
+ cursor: inherit;
55457
+ }
55458
+
55459
+ .navi_select_arrow {
55460
+ position: absolute;
55461
+ top: 50%;
55462
+ right: var(--x-padding-right);
55463
+ display: flex;
55464
+ color: var(--color-dimmed);
55465
+ translate: 0 -50%;
55466
+ /* The arrow is drawn on top of the control it belongs to: a click on it
55467
+ must reach the select and open the list. */
55468
+ pointer-events: none;
55469
+ }
55470
+ }
55471
+ `;
55472
+
55473
+ /**
55474
+ * @type {import("ignore:preact").FunctionComponent<{
55475
+ * value?: string,
55476
+ * defaultValue?: string,
55477
+ * signal?: import("@preact/signals").Signal<string>,
55478
+ * name?: string,
55479
+ * width?: string,
55480
+ * [key: string]: any,
55481
+ * }>}
55482
+ * @param {string} [value] The choice the control is GIVEN — what is already
55483
+ * saved. A form holding it considers that field as already sent.
55484
+ * @param {string} [defaultValue] The choice the control PROPOSES, and what a
55485
+ * reset goes back to. Sending it back is an answer, so a form counts it as
55486
+ * something to send.
55487
+ * @param {string} [width] The control's width. Left out, the box takes the
55488
+ * width of its widest option.
55489
+ */
55490
+ const Select = ({
55491
+ width,
55492
+ multiple,
55493
+ ...props
55494
+ }) => {
55495
+ import.meta.css = [inputCss + css$l, "@jsenv/navi/src/control/input/select.jsx"];
55496
+ const defaultRef = useRef(null);
55497
+ props.ref = props.ref || defaultRef;
55498
+ seedDefaultValueFromSignal(props);
55499
+ const [rootProps, hostProps] = useControlProps(props, {
55500
+ controlType: "select"
55501
+ });
55502
+ const {
55503
+ basePseudoState
55504
+ } = hostProps;
55505
+ // `type` on a <select> is the browser's own read-only "select-one".
55506
+ delete hostProps.type;
55507
+ const loading = basePseudoState[":-navi-loading"];
55508
+ if (width !== undefined) {
55509
+ // On the select, not on the box around it: the box is fit-content and the
55510
+ // control is what has a width to give.
55511
+ hostProps.width = width;
55512
+ }
55513
+ return jsxs(Box, {
55514
+ as: "span",
55515
+ inline: true,
55516
+ flex: true,
55517
+ baseClassName: "navi_input",
55518
+ className: "navi_select",
55519
+ ...rootProps,
55520
+ basePseudoState: basePseudoState,
55521
+ styleCSSVars: InputStyleCSSVars,
55522
+ pseudoStateSelector: ".navi_control_input",
55523
+ pseudoClasses: InputPseudoClasses,
55524
+ pseudoElements: InputPseudoElements,
55525
+ "data-callout-anchor": ".navi_control_input",
55526
+ children: [jsx(LoadingOutline, {
55527
+ loading: loading,
55528
+ color: "var(--loader-color)",
55529
+ inset: -1
55530
+ }), jsx(Box, {
55531
+ ...hostProps,
55532
+ as: "select",
55533
+ baseClassName: "navi_control_input"
55534
+ }), jsx("span", {
55535
+ className: "navi_select_arrow",
55536
+ children: jsx(Icon, {
55537
+ lineOverflow: "allow",
55538
+ children: jsx(ChevronDownSvg$1, {})
55539
+ })
55540
+ })]
55541
+ });
55542
+ };
55543
+
54854
55544
  /**
54855
55545
  * applySearch — matches value against searchText.
54856
55546
  *
@@ -63433,5 +64123,5 @@ const UserSvg = () => jsx("svg", {
63433
64123
  })
63434
64124
  });
63435
64125
 
63436
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
64126
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, StarSvg, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineNaviConfirmPopupOptions, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isToday, languagesSignal, localStorageSignal, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setupRoutes, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, valueInLocalStorage, windowWidthSignal };
63437
64127
  //# sourceMappingURL=jsenv_navi.js.map