@jsenv/navi 0.29.16 → 0.29.18

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";
@@ -7267,6 +7267,367 @@ const findControlRoot = (el) => {
7267
7267
  return null;
7268
7268
  };
7269
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
+
7270
7631
  /**
7271
7632
  * DOM utilities for the proxy control pattern.
7272
7633
  *
@@ -7298,6 +7659,7 @@ const findControlRoot = (el) => {
7298
7659
  * entirely. For now we keep the proxy pattern.
7299
7660
  */
7300
7661
 
7662
+
7301
7663
  /**
7302
7664
  * Given a proxy element, returns the real control it represents.
7303
7665
  * Returns `null` when `el` is not a proxy.
@@ -7310,22 +7672,49 @@ const findControlProxyTarget = (el) => {
7310
7672
  return document.getElementById(proxyFor);
7311
7673
  };
7312
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
+
7313
7705
  /**
7314
7706
  * Given a real control element, returns the proxy that visually represents it.
7315
7707
  *
7316
- * Use when you need to update or recheck the proxy's visual state after the
7317
- * real control's state changes, or when anchoring a callout to the visible
7318
- * 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.
7319
7712
  *
7320
7713
  * Returns `null` when no proxy exists for `el`.
7321
7714
  */
7322
7715
  const findControlProxy = (el) => {
7323
- if (!el.id) {
7324
- return null;
7325
- }
7326
- return document.querySelector(
7327
- `[navi-control-proxy-for="${CSS.escape(el.id)}"]`,
7328
- );
7716
+ const [firstProxy = null] = findControlProxies(el);
7717
+ return firstProxy;
7329
7718
  };
7330
7719
 
7331
7720
  const addInputEffect = (
@@ -7584,56 +7973,16 @@ const listenInputStateChange = (
7584
7973
  return teardown;
7585
7974
  };
7586
7975
 
7587
- const dispatchRequestSetUIState = (element, value, detail) => {
7588
- const controlHost = findControlHost(element) || element;
7589
- return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", {
7590
- ...detail,
7591
- value,
7592
- });
7593
- };
7594
- const dispatchRequestClearUIState = (element, e) => {
7595
- const controlHost = findControlHost(element) || element;
7596
- return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", {
7597
- event: e,
7598
- });
7599
- };
7600
- const dispatchRequestResetUIState = (element, e) => {
7601
- const controlHost = findControlHost(element) || element;
7602
- return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", {
7603
- event: e,
7604
- });
7605
- };
7606
- /**
7607
- * @param {Element} el
7608
- * @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF.
7609
- * Only a button ever answers differently — one with no value of its own
7610
- * inherits the value of the control around it, which is what makes
7611
- * `--navi-send` on a form's button be about that form. Something asking what
7612
- * THIS element says (a travel command reading what the travel is about) wants
7613
- * the own value and would otherwise be handed the surrounding control's.
7614
- */
7615
- const getUIStateFromElement = (el, { own } = {}) => {
7616
- let uiState;
7617
- dispatchInternalCustomEvent(el, "navi_get_ui_state", {
7618
- own,
7619
- respondWith: (v) => {
7620
- uiState = v;
7621
- },
7622
- });
7623
- return uiState;
7624
- };
7625
-
7626
7976
  const requestPseudoStateCheck = (element, detail) => {
7627
7977
  dispatchInternalCustomEvent(
7628
7978
  element,
7629
7979
  "navi_pseudo_state_request_check",
7630
7980
  detail,
7631
7981
  );
7632
- // When a control has a visible proxy mirroring its state (e.g. selectable
7633
- // radio with `navi-control-proxy-for`), re-check the proxy too so it stays
7634
- // in sync with the real control.
7635
- const proxy = findControlProxy(element);
7636
- 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)) {
7637
7986
  dispatchInternalCustomEvent(
7638
7987
  proxy,
7639
7988
  "navi_pseudo_state_request_check",
@@ -7686,8 +8035,7 @@ definePseudoClass(":hover", {
7686
8035
  return () => {};
7687
8036
  }
7688
8037
  const recheckProxy = (e) => {
7689
- const proxy = findControlProxy(el);
7690
- if (proxy) {
8038
+ for (const proxy of findControlProxies(el)) {
7691
8039
  requestPseudoStateCheck(proxy, { event: e });
7692
8040
  }
7693
8041
  };
@@ -7754,9 +8102,10 @@ definePseudoClass(":hover", {
7754
8102
  if (el.matches(":hover")) {
7755
8103
  return true;
7756
8104
  }
7757
- const proxy = findControlProxy(el);
7758
- if (proxy && proxy.matches(":hover")) {
7759
- return true;
8105
+ for (const proxy of findControlProxies(el)) {
8106
+ if (proxy.matches(":hover")) {
8107
+ return true;
8108
+ }
7760
8109
  }
7761
8110
  return false;
7762
8111
  },
@@ -8118,23 +8467,30 @@ const isKeyboardModality = () => keyboardNavigationUsed;
8118
8467
  requireFocusVisible
8119
8468
  ? isMatchingFocusVisible(target)
8120
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.
8121
8476
  const isControlledBy = (target) => {
8122
8477
  const id = target.id;
8123
8478
  if (!id) {
8124
8479
  return false;
8125
8480
  }
8126
- const controllers = document.querySelectorAll(`[aria-controls~="${id}"]`);
8127
- for (const controller of controllers) {
8128
- // If the controller is inside the element it controls, focus is already
8129
- // native (:focus-within) — no need to inherit it.
8130
- if (target.contains(controller)) {
8131
- continue;
8132
- }
8133
- if (isFocusedTarget(controller)) {
8134
- return true;
8135
- }
8481
+ const activeElement = document.activeElement;
8482
+ if (!activeElement || activeElement === document.body) {
8483
+ return false;
8136
8484
  }
8137
- 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);
8138
8494
  };
8139
8495
  if (isControlledBy(el)) {
8140
8496
  return true;
@@ -8461,8 +8817,7 @@ const initPseudoStyles = (
8461
8817
  }
8462
8818
  // When this element's state changes, notify any proxy element that mirrors it
8463
8819
  // so it can re-check and visually reflect the new state.
8464
- const proxy = findControlProxy(element);
8465
- if (proxy) {
8820
+ for (const proxy of findControlProxies(element)) {
8466
8821
  requestPseudoStateCheck(proxy, {});
8467
8822
  }
8468
8823
  };
@@ -9760,7 +10115,7 @@ const setupNetworkMonitoring = () => {
9760
10115
  };
9761
10116
  setupNetworkMonitoring();
9762
10117
 
9763
- installImportMetaCssBuild(import.meta);const css$Y = /* css */`
10118
+ installImportMetaCssBuild(import.meta);const css$Z = /* css */`
9764
10119
  .navi_loading_indicator_fluid_container {
9765
10120
  position: relative;
9766
10121
  display: flex;
@@ -9792,7 +10147,7 @@ const LoadingIndicatorFluid = ({
9792
10147
  visuallyHidden,
9793
10148
  ...rest
9794
10149
  }) => {
9795
- 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"];
9796
10151
  const ref = useRef(null);
9797
10152
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
9798
10153
  const [containerWidth, setContainerWidth] = useState(0);
@@ -9997,7 +10352,7 @@ const LoadingRectangleSvg = ({
9997
10352
  });
9998
10353
  };
9999
10354
 
10000
- installImportMetaCssBuild(import.meta);const css$X = /* css */`
10355
+ installImportMetaCssBuild(import.meta);const css$Y = /* css */`
10001
10356
  .navi_loading_outline_wrapper {
10002
10357
  position: absolute;
10003
10358
  /* Controls place the outline slightly outside their box, right on top of
@@ -10034,7 +10389,7 @@ installImportMetaCssBuild(import.meta);const css$X = /* css */`
10034
10389
  }
10035
10390
  `;
10036
10391
  const LoadingOutline = props => {
10037
- 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"];
10038
10393
  if (props.containerRef) {
10039
10394
  const container = props.containerRef.current;
10040
10395
  if (!container) {
@@ -10368,7 +10723,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
10368
10723
  };
10369
10724
 
10370
10725
  installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
10371
- const css$W = /* css */`
10726
+ const css$X = /* css */`
10372
10727
  @layer navi {
10373
10728
  .navi_text {
10374
10729
  &[data-skeleton] {
@@ -10874,7 +11229,7 @@ const TextShrinkWrap = props => {
10874
11229
  });
10875
11230
  };
10876
11231
  const TextUI = props => {
10877
- import.meta.css = [css$W, "@jsenv/navi/src/text/text.jsx"];
11232
+ import.meta.css = [css$X, "@jsenv/navi/src/text/text.jsx"];
10878
11233
  let {
10879
11234
  ref,
10880
11235
  spacing,
@@ -13191,310 +13546,6 @@ const useActionStatus = (action) => {
13191
13546
  };
13192
13547
  };
13193
13548
 
13194
- /**
13195
- * Converts a JS value into the form expected by the browser DOM property for a
13196
- * given control type/input type combination.
13197
- *
13198
- * For example:
13199
- * - `datetime-local` inputs expect a local datetime string without timezone
13200
- * - `number`/`range` inputs expect a numeric string or number
13201
- * - `color` inputs require a non-empty hex string (falls back to `#000000`)
13202
- * - All other inputs receive the value as-is (undefined → "")
13203
- *
13204
- * Returns either the converted value directly, or a converter function when the
13205
- * conversion depends on the runtime value (e.g. plain inputs return `asInputValue`).
13206
- *
13207
- * @param {any} value - The JS value to convert.
13208
- * @param {{ controlType: string, type: string }} options
13209
- * @returns {any} The DOM-compatible value or a converter function.
13210
- */
13211
- const asControlHostValue = (
13212
- jsValue,
13213
- { controlType, type, inputMode },
13214
- ) => {
13215
- if (controlType === "input" || controlType === "picker") {
13216
- if (type === "datetime-local") {
13217
- return asDatetimeLocalString(jsValue);
13218
- }
13219
- if (
13220
- type === "number" ||
13221
- type === "range" ||
13222
- inputMode === "numeric" ||
13223
- inputMode === "decimal"
13224
- ) {
13225
- return asNumberString(jsValue);
13226
- }
13227
- if (type === "color") {
13228
- return asColorString(jsValue);
13229
- }
13230
- return asInputValue(jsValue);
13231
- }
13232
- return jsValue;
13233
- };
13234
- // As explained in https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/datetime-local#setting_timezones
13235
- // datetime-local does not support timezones
13236
- const asDatetimeLocalString = (dateTimeString) => {
13237
- const date = new Date(dateTimeString);
13238
- if (isNaN(date.getTime())) {
13239
- return dateTimeString;
13240
- }
13241
- const year = date.getFullYear();
13242
- const month = String(date.getMonth() + 1).padStart(2, "0");
13243
- const day = String(date.getDate()).padStart(2, "0");
13244
- const hours = String(date.getHours()).padStart(2, "0");
13245
- const minutes = String(date.getMinutes()).padStart(2, "0");
13246
- const seconds = String(date.getSeconds()).padStart(2, "0");
13247
- return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
13248
- };
13249
- const asNumberString = (jsValue) => {
13250
- if (jsValue === undefined) {
13251
- return "";
13252
- }
13253
- return jsValue;
13254
- };
13255
- // Browser requires a non-empty value for <input type="color">.
13256
- // When our logical value is empty we give it #000000 so it doesn't choke.
13257
- // The UI uses the original (possibly empty) value to show the checkerboard.
13258
- const asColorString = (jsValue) => {
13259
- return jsValue || "#000000";
13260
- };
13261
- const asInputValue = (jsValue) => {
13262
- if (jsValue === undefined) {
13263
- return "";
13264
- }
13265
- return jsValue;
13266
- };
13267
-
13268
- /**
13269
- * Reads the current logical JS value from a control host DOM element.
13270
- *
13271
- * Handles all navi control host element types:
13272
- * - `<button>` — reads via `navi_get_value` custom event, falls back to `button.value`
13273
- * - `<input type="number|range">` — parses as a number, returns `undefined` when empty
13274
- * - `<input type="checkbox|radio">` — returns `undefined` when unchecked, otherwise reads
13275
- * via `navi_get_value` custom event (to preserve the original JS type of the value prop)
13276
- * - `<input type="datetime-local">` — converts the local datetime string to an ISO 8601 string
13277
- * - `<input type="navi_picker">` — delegates to the controller via `navi_get_ui_state`
13278
- * - All other inputs — returns `input.value` as a string
13279
- *
13280
- * @param {HTMLElement} controlHost - The control host DOM element to read from.
13281
- * @returns {any} The current logical value of the control.
13282
- */
13283
- const readControlValue = (controlHost) => {
13284
- if (
13285
- controlHost.tagName === "BUTTON" ||
13286
- controlHost.getAttribute("role") === "button"
13287
- ) {
13288
- return readValueFromButton(controlHost);
13289
- }
13290
- if (controlHost.tagName === "INPUT") {
13291
- // important: input.type = "navi_js"; followed by input.type; returns "text"
13292
- // so use getAttribute
13293
- const type = controlHost.getAttribute("type");
13294
-
13295
- if (
13296
- type === "number" ||
13297
- type === "range" ||
13298
- controlHost.inputMode === "numeric" ||
13299
- controlHost.inputMode === "decimal"
13300
- ) {
13301
- return readNumberFromInput(controlHost);
13302
- }
13303
- if (type === "color") {
13304
- return readValueFromControlHost(controlHost);
13305
- }
13306
- if (type === "checkbox" || type === "radio") {
13307
- return readValueFromCheckableInput(controlHost);
13308
- }
13309
- if (type === "datetime-local") {
13310
- return readDatetimeLocalFromInput(controlHost);
13311
- }
13312
- if (type === "navi_js") {
13313
- return getUIStateFromElement(controlHost);
13314
- }
13315
- return readValueFromInput(controlHost);
13316
- }
13317
- if (controlHost.hasAttribute("navi-control-host")) {
13318
- // Non-button, non-input navi controls (e.g. Badge.Button rendered as span)
13319
- return readValueFromControlHost(controlHost);
13320
- }
13321
- return readValueFromElement(controlHost);
13322
- };
13323
- const readValueFromControlHost = (controlHost) => {
13324
- return readValueFromNaviCustomEvent(controlHost, controlHost.value);
13325
- };
13326
- const readValueFromButton = (button) => {
13327
- return readValueFromControlHost(button);
13328
- };
13329
- const readDatetimeLocalFromInput = (input) => {
13330
- const localDateTimeString = input.value;
13331
- if (localDateTimeString === "") {
13332
- return "";
13333
- }
13334
- const localDate = new Date(localDateTimeString);
13335
- if (isNaN(localDate.getTime())) {
13336
- return localDateTimeString;
13337
- }
13338
- return localDate.toISOString();
13339
- };
13340
- const readNumberFromInput = (input) => {
13341
- const numberString = input.value;
13342
- if (numberString === "") {
13343
- return "";
13344
- }
13345
- const asNumber = Number(numberString);
13346
- if (isNaN(asNumber)) {
13347
- return numberString;
13348
- }
13349
- return asNumber;
13350
- };
13351
- const readValueFromCheckableInput = (input) => {
13352
- const checked = input.checked;
13353
- if (!checked) {
13354
- return undefined;
13355
- }
13356
- return readValueFromControlHost(input);
13357
- };
13358
- const readValueFromInput = (input) => {
13359
- const value = input.value;
13360
- return value;
13361
- };
13362
- const readValueFromElement = (element) => {
13363
- const value = element.value;
13364
- return value;
13365
- };
13366
- const readValueFromNaviCustomEvent = (field, fallback) => {
13367
- // prefer the value given as prop (respect original type, browser would convert to string)
13368
- let responded;
13369
- let value;
13370
- dispatchCustomEvent(field, "navi_get_value", {
13371
- respondWith: (jsValue) => {
13372
- responded = true;
13373
- value = jsValue;
13374
- },
13375
- });
13376
- if (responded) {
13377
- return value;
13378
- }
13379
- return fallback;
13380
- };
13381
-
13382
- // In-memory registry of all mounted ui state controllers keyed by their id.
13383
- // Allows direct controller access without dispatching DOM events — used by external
13384
- // callers (e.g. selectable_list) to call setUIState by id instead of via the DOM.
13385
- const controllersById = new Map();
13386
-
13387
- // In-memory registry for radio controllers, keyed by input name.
13388
- // Allows radio sibling unchecking without querying the DOM — necessary when
13389
- // items are virtualized and their DOM element may not exist at the time.
13390
- // Form scoping is reproduced by comparing parentUIStateController references.
13391
- const radioControllersByName = new Map();
13392
-
13393
- // Registry for non-serializable JS values that cannot be written to DOM attributes as-is.
13394
- // When a value is an object/array, we store it here and write a reference string to the DOM
13395
- // instead of "[object Object]". Console-inspectable via window.__navi_js('id').
13396
- // The controller id is used as key — if the controller has no id, the value is not registered.
13397
- const naviJsRegistry = new Map();
13398
-
13399
- const getUIStateControllerById = (id) => controllersById.get(id);
13400
- const getRadioSiblings = (radioUIStateController) => {
13401
- const siblings = radioControllersByName.get(radioUIStateController.name);
13402
- return siblings;
13403
- };
13404
-
13405
- const toDomValue = (jsValue, { controlType, id, type, inputMode }) => {
13406
- const domValue = asControlHostValue(jsValue, {
13407
- controlType,
13408
- type,
13409
- inputMode,
13410
- });
13411
- if (isSerializableAsDomValue(domValue)) {
13412
- return domValue;
13413
- }
13414
- naviJsRegistry.set(id, domValue);
13415
- return `window.__navi_js('${id}')`;
13416
- };
13417
-
13418
- window.__navi_js = (id) => naviJsRegistry.get(id);
13419
- const isSerializableAsDomValue = (value) => {
13420
- if (value === null || value === undefined) {
13421
- return true;
13422
- }
13423
- const type = typeof value;
13424
- return type === "string" || type === "number" || type === "boolean";
13425
- };
13426
-
13427
- const onUIStateControllerCreated = (uiStateController) => {
13428
- const { id, name, controlType } = uiStateController;
13429
- if (id) {
13430
- controllersById.set(id, uiStateController);
13431
- }
13432
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
13433
- if (proxyFor) {
13434
- proxyControllerByRealInputId.set(proxyFor, uiStateController);
13435
- }
13436
- if (
13437
- controlType === "input" &&
13438
- uiStateController.props.type === "radio" &&
13439
- name
13440
- ) {
13441
- let set = radioControllersByName.get(name);
13442
- if (!set) {
13443
- set = new Set();
13444
- radioControllersByName.set(name, set);
13445
- }
13446
- set.add(uiStateController);
13447
- }
13448
- };
13449
- const onUIStateControllerDestroyed = (uiStateController) => {
13450
- const { id, name, controlType } = uiStateController;
13451
- if (id) {
13452
- controllersById.delete(id);
13453
- naviJsRegistry.delete(id);
13454
- }
13455
- const proxyFor = uiStateController.props["navi-control-proxy-for"];
13456
- if (proxyFor) {
13457
- proxyControllerByRealInputId.delete(proxyFor);
13458
- }
13459
- if (
13460
- controlType === "input" &&
13461
- uiStateController.controlHostProps.type === "radio" &&
13462
- name
13463
- ) {
13464
- const set = radioControllersByName.get(name);
13465
- if (set) {
13466
- set.delete(uiStateController);
13467
- if (set.size === 0) {
13468
- radioControllersByName.delete(name);
13469
- }
13470
- }
13471
- }
13472
- };
13473
-
13474
- /**
13475
- * Controller-based equivalent of findControlProxyTarget.
13476
- * Given a proxy controller, returns the real control's controller.
13477
- * Finds the target by walking the parent controller's children — no DOM queries.
13478
- * Returns `null` when the controller is not a proxy or the target is not found.
13479
- */
13480
- const findControlProxyTargetController = (controller) => {
13481
- const proxyFor = controller.controlHostProps["navi-control-proxy-for"];
13482
- if (!proxyFor) {
13483
- return null;
13484
- }
13485
- return getUIStateControllerById(proxyFor) ?? null;
13486
- };
13487
-
13488
- // Reverse-lookup map: real-input id → proxy controller that references it via
13489
- // `navi-control-proxy-for`. Maintained on create/destroy so lookup is O(1).
13490
- const proxyControllerByRealInputId = new Map();
13491
- const findProxyController = (realInputId) => {
13492
- if (!realInputId) {
13493
- return null;
13494
- }
13495
- return proxyControllerByRealInputId.get(realInputId) ?? null;
13496
- };
13497
-
13498
13549
  const CONSTRAINT_NAME_TO_PROP = {
13499
13550
  disabled: "disabledMessage",
13500
13551
  required: "requiredMessage",
@@ -13595,7 +13646,7 @@ installImportMetaCssBuild(import.meta);/**
13595
13646
  * - Arrow automatically shows when pointing at a valid anchor element
13596
13647
  * - Centers in viewport when no anchor element provided or anchor is too big
13597
13648
  */
13598
- const css$V = /* css */`
13649
+ const css$W = /* css */`
13599
13650
  @layer navi {
13600
13651
  .navi_callout {
13601
13652
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -13834,7 +13885,7 @@ const openCallout = (message, {
13834
13885
  skipFocus = false,
13835
13886
  debug = () => {}
13836
13887
  } = {}) => {
13837
- 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"];
13838
13889
  if (debug === true) {
13839
13890
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
13840
13891
  }
@@ -14435,12 +14486,12 @@ const positionCallout = (calloutElement, anchorElement, {
14435
14486
  } else if (anchorElement.hasAttribute("data-callout-point-to-content-box")) {
14436
14487
  alignToAnchorBox = "content-box";
14437
14488
  } else {
14438
- // 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.
14439
14490
  // For everything else (labels, divs, fieldsets…) content-box maximizes the chance
14440
14491
  // the arrow points at visible text rather than the outer padding/border.
14441
14492
  const controHost = findControlHost(anchorElement) || anchorElement;
14442
14493
  const tagName = controHost.tagName;
14443
- if (tagName === "INPUT" || tagName === "BUTTON" || tagName === "FIELDSET") {
14494
+ if (tagName === "INPUT" || tagName === "SELECT" || tagName === "BUTTON" || tagName === "FIELDSET") {
14444
14495
  alignToAnchorBox = "border-box";
14445
14496
  } else {
14446
14497
  alignToAnchorBox = "content-box";
@@ -17916,6 +17967,10 @@ const isInertOnClick = (element) => {
17916
17967
  if (tagName === "BUTTON") {
17917
17968
  return element.type === "button";
17918
17969
  }
17970
+ if (tagName === "SELECT") {
17971
+ // The click opens the option list; cancelling it leaves the select shut.
17972
+ return false;
17973
+ }
17919
17974
  return true;
17920
17975
  };
17921
17976
 
@@ -19734,6 +19789,78 @@ createContext();
19734
19789
  const ActionContext = createContext();
19735
19790
  const ActionRequesterContext = createContext();
19736
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
+
19737
19864
  /**
19738
19865
  * Named presets for the `charGuard` prop.
19739
19866
  * Each value is a regex character class (including the [ ] delimiters).
@@ -20337,15 +20464,17 @@ const useUIStateController = (
20337
20464
  // later through a React re-render — visible as e.g. two radios
20338
20465
  // appearing checked at once between the real input update and the
20339
20466
  // next render (radio_sibling_uncheck case).
20340
- const proxyController = findProxyController(s.id);
20341
- if (proxyController) {
20342
- // Find any mounted controller that declared itself as a proxy for this one.
20343
- // Communicates directly to the proxy controller — no DOM query needed.
20344
- const mirrorEvent = new CustomEvent("proxy_mirror_state", {
20345
- detail: {},
20346
- });
20347
- chainEvent(mirrorEvent, e);
20348
- 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
+ }
20349
20478
  }
20350
20479
  }
20351
20480
  if (isInternalEvent(e)) {
@@ -20462,8 +20591,18 @@ const useUIStateController = (
20462
20591
  );
20463
20592
  syntheticInputFired = true;
20464
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;
20465
20604
  }
20466
- // TODO: select, textarea
20605
+ // TODO: textarea
20467
20606
  }
20468
20607
  }
20469
20608
  if (!syntheticInputFired) {
@@ -21952,6 +22091,61 @@ const useControlProps = (props, {
21952
22091
  }
21953
22092
  };
21954
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
+ }
21955
22149
  const keyDownDefaultOnInput = e => {
21956
22150
  if (e.key === "Enter") {
21957
22151
  if (actionDebounce) {
@@ -21960,15 +22154,7 @@ const useControlProps = (props, {
21960
22154
  // Don't propagate to --navi-send, which would cause a double action call.
21961
22155
  return null;
21962
22156
  }
21963
- const input = e.currentTarget;
21964
- return {
21965
- name: "enter on input to send closest control group",
21966
- bypassInteractivity: true,
21967
- // allow to dispatch --navi-send even if input is readonly
21968
- allowed: () => triggerNaviCommand(input, "--navi-send", e),
21969
- // prevent dispatching click as result of this enter
21970
- prevented: () => e.preventDefault()
21971
- };
22157
+ return enterToSend(e);
21972
22158
  }
21973
22159
  return keyDownDefault(e);
21974
22160
  };
@@ -22364,7 +22550,7 @@ const createControlInfo = (props, {
22364
22550
  defaultStatePropName = "defaultOpen";
22365
22551
  stateInitial = props.open || props.defaultOpen;
22366
22552
  value = props.value || "open";
22367
- } else if (controlType === "picker") {
22553
+ } else if (controlType === "picker" || controlType === "select") {
22368
22554
  statePropName = "value";
22369
22555
  defaultStatePropName = "defaultValue";
22370
22556
  if (Object.hasOwn(props, "value")) {
@@ -22383,7 +22569,10 @@ const createControlInfo = (props, {
22383
22569
  stateInitial = undefined;
22384
22570
  }
22385
22571
  disabledSupported = true;
22386
- 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);
22387
22576
  }
22388
22577
  return {
22389
22578
  controlType,
@@ -22709,39 +22898,47 @@ const useInteractiveProps = (props, {
22709
22898
  controlHostProps["inert"] = "";
22710
22899
  }
22711
22900
  }
22712
- // inform any associated label of our state (connected, disabled, readOnly,
22901
+ // Inform any associated label of our state (connected, disabled, readOnly,
22713
22902
  // required — a Label with requiredIndicator marks itself from it rather
22714
- // than being told twice what the control already knows)
22715
- // dispatched directly on the label works whether the label wraps the control
22716
- // (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);
22717
22913
  useLayoutEffect(() => {
22718
22914
  const element = ref.current;
22719
22915
  if (!element) {
22720
22916
  return;
22721
22917
  }
22722
- const labels = getAssociatedLabels(element);
22723
22918
  const readOnlyForced = element.hasAttribute("data-readonly-forced");
22724
22919
  const readOnly = readOnlyForced ? false : readOnlyResolved;
22725
- for (const label of labels) {
22920
+ const controlState = {
22921
+ disabled: disabledResolved,
22922
+ readOnly,
22923
+ required: requiredResolved
22924
+ };
22925
+ for (const label of getAssociatedLabels(element)) {
22726
22926
  label.dispatchEvent(new CustomEvent("navi_control_state", {
22727
- detail: {
22728
- disabled: disabledResolved,
22729
- readOnly,
22730
- required: requiredResolved
22731
- }
22927
+ detail: controlState
22732
22928
  }));
22733
22929
  }
22930
+ publishedIdRef.current = element.id;
22931
+ publishControlStateToLabels(element.id, controlState);
22734
22932
  }, [disabledResolved, readOnlyResolved, requiredResolved, ref]);
22735
22933
  useLayoutEffect(() => {
22736
22934
  return () => {
22737
22935
  const element = ref.current;
22738
- if (!element) {
22739
- return;
22740
- }
22741
- const labels = getAssociatedLabels(element);
22742
- for (const label of labels) {
22743
- 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
+ }
22744
22940
  }
22941
+ unpublishControlStateToLabels(publishedIdRef.current);
22745
22942
  };
22746
22943
  }, []);
22747
22944
  }
@@ -22991,29 +23188,19 @@ const splitControlProps = props => {
22991
23188
  }
22992
23189
  return [controlRootProps, controlHostProps];
22993
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.
22994
23196
  const getAssociatedLabels = element => {
22995
- if (!element) {
23197
+ if (!element || !element.labels) {
22996
23198
  return [];
22997
23199
  }
22998
- // const closestPicker = element.closest('[navi-control="picker"]');
22999
- // const insidePicker = closestPicker && element !== closestPicker;
23000
- // const formElement = insidePicker ? closestPicker : element;
23001
- const formElement = element;
23002
- // Native form elements expose .labels directly
23003
- if (formElement.labels && formElement.labels.length > 0) {
23004
- return Array.from(formElement.labels);
23005
- }
23006
- const id = formElement.id;
23007
- if (id) {
23008
- const byId = Array.from(document.querySelectorAll(`label[for="${CSS.escape(id)}"]`));
23009
- if (byId.length > 0) {
23010
- return byId;
23011
- }
23012
- }
23013
- return [];
23200
+ return Array.from(element.labels);
23014
23201
  };
23015
23202
 
23016
- installImportMetaCssBuild(import.meta);const css$U = /* css */`
23203
+ installImportMetaCssBuild(import.meta);const css$V = /* css */`
23017
23204
  @layer navi {
23018
23205
  .navi_button {
23019
23206
  --button-border-radius: var(--navi-control-border-radius);
@@ -23406,7 +23593,7 @@ installImportMetaCssBuild(import.meta);const css$U = /* css */`
23406
23593
  }
23407
23594
  `;
23408
23595
  const ButtonUI = props => {
23409
- 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"];
23410
23597
  const {
23411
23598
  ref,
23412
23599
  // href/link
@@ -24818,6 +25005,10 @@ const createOpenController = (
24818
25005
  const controller = {
24819
25006
  opened: false,
24820
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,
24821
25012
  open: (e, detail) => {
24822
25013
  if (controller.opened || !controller.openEffect) {
24823
25014
  return;
@@ -24871,6 +25062,10 @@ const createOpenController = (
24871
25062
  }
24872
25063
  };
24873
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?.();
24874
25069
  const openEffectReturnValue =
24875
25070
  controller.openEffect(requestOpenEvent) || null;
24876
25071
  openEffectCleanup = (closeEvent) => {
@@ -25064,6 +25259,84 @@ const useOpenPropsEffectOnOpenController = (openController, props) => {
25064
25259
  }, [open]);
25065
25260
  };
25066
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
+
25067
25340
  /**
25068
25341
  * Entry/exit animation CSS shared by Popover and Dialog.
25069
25342
  *
@@ -25534,7 +25807,7 @@ installImportMetaCssBuild(import.meta);/**
25534
25807
  * reaches the real container.
25535
25808
  */
25536
25809
  let openLocalDialogCount = 0;
25537
- const css$T = /* css */`
25810
+ const css$U = /* css */`
25538
25811
  @layer navi {
25539
25812
  .navi_dialog {
25540
25813
  /* Min gap between the dialog and the edges of its container. Written
@@ -25941,10 +26214,15 @@ const css$T = /* css */`
25941
26214
  * open controller (see `open_controller.js`) for a caller that wants to
25942
26215
  * drive open/close itself instead of `open`/`defaultOpen`/`onClose` (used
25943
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.
25944
26222
  * @param {import("ignore:preact").ComponentChildren} props.children
25945
26223
  */
25946
26224
  const Dialog = props => {
25947
- import.meta.css = [css$T, "@jsenv/navi/src/layout/dialog.jsx"];
26225
+ import.meta.css = [css$U, "@jsenv/navi/src/layout/dialog.jsx"];
25948
26226
  if (props.openController) {
25949
26227
  return jsx(ControlledDialog, {
25950
26228
  ...props
@@ -26132,9 +26410,14 @@ const useDialogProps = props => {
26132
26410
  // instead, so it's read here rather than left in `rest`.
26133
26411
  autoFocus = "last-resort",
26134
26412
  onKeyDown,
26135
- children,
26413
+ children: childrenProp,
26414
+ mountWhenClosed,
26136
26415
  ...rest
26137
26416
  } = props;
26417
+ const children = usePopupContentMount(openController, {
26418
+ children: childrenProp,
26419
+ mountWhenClosed
26420
+ });
26138
26421
  const isModal = layer === "top";
26139
26422
  const ref = props.ref;
26140
26423
  // Only touch changes anything: with a mouse a dialog already wants to be the
@@ -26772,7 +27055,7 @@ installImportMetaCssBuild(import.meta);/**
26772
27055
  * and applied.
26773
27056
  */
26774
27057
  let openLocalPopoverCount = 0;
26775
- const css$S = /* css */`
27058
+ const css$T = /* css */`
26776
27059
  @layer navi {
26777
27060
  .navi_popover {
26778
27061
  /* soft: user-configurable preferred max-height. Kept as a *default*
@@ -27139,10 +27422,15 @@ const css$S = /* css */`
27139
27422
  * open controller (see `open_controller.js`) for a caller that wants to
27140
27423
  * drive open/close itself instead of `open`/`defaultOpen`/`onClose` (used
27141
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.
27142
27430
  * @param {import("ignore:preact").ComponentChildren} props.children
27143
27431
  */
27144
27432
  const Popover = props => {
27145
- import.meta.css = [css$S, "@jsenv/navi/src/layout/popover.jsx"];
27433
+ import.meta.css = [css$T, "@jsenv/navi/src/layout/popover.jsx"];
27146
27434
  if (props.openController) {
27147
27435
  return jsx(ControlledPopover, {
27148
27436
  ...props
@@ -27323,9 +27611,14 @@ const usePopoverProps = props => {
27323
27611
  // instead, so it's read here rather than left in `rest`.
27324
27612
  autoFocus = "last-resort",
27325
27613
  onKeyDown,
27326
- children,
27614
+ children: childrenProp,
27615
+ mountWhenClosed,
27327
27616
  ...rest
27328
27617
  } = props;
27618
+ const children = usePopupContentMount(openController, {
27619
+ children: childrenProp,
27620
+ mountWhenClosed
27621
+ });
27329
27622
  const isTopLayer = layer === "top";
27330
27623
  const ref = props.ref;
27331
27624
  const backdropRef = useRef();
@@ -28106,7 +28399,7 @@ installImportMetaCssBuild(import.meta);/**
28106
28399
  * event, and a caller replacing the body entirely then has one protocol to
28107
28400
  * follow — `--navi-confirm` for yes, anything that closes for no.
28108
28401
  */
28109
- const css$R = /* css */`
28402
+ const css$S = /* css */`
28110
28403
  /* The width lives on the body rather than on the popup, so that custom
28111
28404
  content (which replaces this body entirely) sizes itself instead of
28112
28405
  inheriting a ceiling meant for a sentence-long question. */
@@ -28243,7 +28536,7 @@ const ConfirmPopup = ({
28243
28536
  onAnswer,
28244
28537
  onClosed
28245
28538
  }) => {
28246
- 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"];
28247
28540
  const {
28248
28541
  mode,
28249
28542
  confirmLabel,
@@ -28327,7 +28620,7 @@ const defaultBody = (message, {
28327
28620
  });
28328
28621
  };
28329
28622
 
28330
- installImportMetaCssBuild(import.meta);const css$Q = /* css */`
28623
+ installImportMetaCssBuild(import.meta);const css$R = /* css */`
28331
28624
  .action_error {
28332
28625
  margin-top: 0;
28333
28626
  margin-bottom: 20px;
@@ -28352,7 +28645,7 @@ const ActionRenderer = ({
28352
28645
  children,
28353
28646
  disabled
28354
28647
  }) => {
28355
- 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"];
28356
28649
  if (action === undefined) {
28357
28650
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
28358
28651
  }
@@ -35547,7 +35840,7 @@ const PhoneSvg = () => {
35547
35840
  };
35548
35841
 
35549
35842
  installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
35550
- const css$P = /* css */`
35843
+ const css$Q = /* css */`
35551
35844
  .navi_text_anchor {
35552
35845
  vertical-align: baseline;
35553
35846
  user-select: none;
@@ -35582,7 +35875,7 @@ const TextAnchor = ({
35582
35875
  textSize,
35583
35876
  lineLayout
35584
35877
  }) => {
35585
- 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"];
35586
35879
  const anchorRef = useRef();
35587
35880
 
35588
35881
  // Plain useLayoutEffect would also fire while an ancestor dialog/popover
@@ -35697,7 +35990,7 @@ const computeTopOffset = ({
35697
35990
  };
35698
35991
  const charTopCanvas = document.createElement("canvas");
35699
35992
 
35700
- installImportMetaCssBuild(import.meta);const css$O = /* css */`
35993
+ installImportMetaCssBuild(import.meta);const css$P = /* css */`
35701
35994
  @layer navi {
35702
35995
  /* Ensure data attributes from box.jsx can win to update display */
35703
35996
  .navi_icon {
@@ -35855,7 +36148,7 @@ const Icon = ({
35855
36148
  fillLine,
35856
36149
  ...props
35857
36150
  }) => {
35858
- import.meta.css = [css$O, "@jsenv/navi/src/text/icon.jsx"];
36151
+ import.meta.css = [css$P, "@jsenv/navi/src/text/icon.jsx"];
35859
36152
  const innerChildren = href ? jsx("svg", {
35860
36153
  width: "100%",
35861
36154
  height: "100%",
@@ -36008,7 +36301,7 @@ const useDimColorWhen = (elementRef, shouldDim) => {
36008
36301
  });
36009
36302
  };
36010
36303
 
36011
- installImportMetaCssBuild(import.meta);const css$N = /* css */`
36304
+ installImportMetaCssBuild(import.meta);const css$O = /* css */`
36012
36305
  @layer navi {
36013
36306
  .navi_link {
36014
36307
  --link-border-radius: unset;
@@ -36450,7 +36743,7 @@ Object.assign(PSEUDO_CLASSES, {
36450
36743
  * @param {boolean} [props.readOnly]
36451
36744
  */
36452
36745
  const Link = props => {
36453
- 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"];
36454
36747
  if (props.route) {
36455
36748
  return jsx(LinkWithRoute, {
36456
36749
  ...props
@@ -36685,7 +36978,7 @@ installImportMetaCssBuild(import.meta);/**
36685
36978
  * TabList component with support for horizontal and vertical layouts
36686
36979
  * https://dribbble.com/search/tabs
36687
36980
  */
36688
- const css$M = /* css */`
36981
+ const css$N = /* css */`
36689
36982
  @layer navi {
36690
36983
  .navi_nav {
36691
36984
  --nav-border: none;
@@ -36860,7 +37153,7 @@ const Nav = ({
36860
37153
  // "before" or "after": which side the panel sits on, turning the nav into folder tabs
36861
37154
  ...props
36862
37155
  }) => {
36863
- 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"];
36864
37157
  children = toChildArray(children);
36865
37158
  return jsx(Box, {
36866
37159
  as: "nav",
@@ -37252,7 +37545,7 @@ installImportMetaCssBuild(import.meta);/**
37252
37545
  * Border width participates in layout (it is added to the tab and page
37253
37546
  * padding): a thick border grows the binder rather than eating into the text.
37254
37547
  */
37255
- const css$L = /* css */`
37548
+ const css$M = /* css */`
37256
37549
  @layer navi {
37257
37550
  .navi_binder {
37258
37551
  --binder-border-width: var(--navi-control-border-width);
@@ -37565,7 +37858,7 @@ const Binder = ({
37565
37858
  pagePadding,
37566
37859
  ...props
37567
37860
  }) => {
37568
- 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"];
37569
37862
  const items = toChildArray(children).map((child, index) => {
37570
37863
  const {
37571
37864
  value: itemValue,
@@ -38091,7 +38384,7 @@ installImportMetaCssBuild(import.meta);/**
38091
38384
  * into the size; a box-shadow draws the identical line and stays out of
38092
38385
  * layout.
38093
38386
  */
38094
- const css$K = /* css */`
38387
+ const css$L = /* css */`
38095
38388
  @layer navi {
38096
38389
  :root {
38097
38390
  --navi-fixed-bar-width: 56px;
@@ -38229,7 +38522,7 @@ const FixedBar = ({
38229
38522
  border = true,
38230
38523
  ...props
38231
38524
  }) => {
38232
- 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"];
38233
38526
  const defaultRef = useRef();
38234
38527
  props.ref = props.ref || defaultRef;
38235
38528
  // Whichever of width/height crosses the edge the bar sits on is what the
@@ -38317,7 +38610,7 @@ const FixedBar = ({
38317
38610
  // Subpixel layout rounds rectangles up on boxes that fit exactly.
38318
38611
  const OVERFLOW_TOLERANCE = 1;
38319
38612
 
38320
- const css$J = /* css */ `
38613
+ const css$K = /* css */ `
38321
38614
  [data-navi-overflow-x] {
38322
38615
  outline: 2px dashed #e74c3c;
38323
38616
  outline-offset: -2px;
@@ -38341,7 +38634,7 @@ const detectHorizontalOverflow = ({
38341
38634
  let styleEl = null;
38342
38635
  if (highlight) {
38343
38636
  styleEl = document.createElement("style");
38344
- styleEl.textContent = css$J;
38637
+ styleEl.textContent = css$K;
38345
38638
  document.head.appendChild(styleEl);
38346
38639
  }
38347
38640
 
@@ -38497,7 +38790,7 @@ const useFocusGroup = (
38497
38790
 
38498
38791
  installImportMetaCssBuild(import.meta);const rightArrowPath = "M680-480L360-160l-80-80 240-240-240-240 80-80 320 320z";
38499
38792
  const downArrowPath = "M480-280L160-600l80-80 240 240 240-240 80 80-320 320z";
38500
- const css$I = /* css */`
38793
+ const css$J = /* css */`
38501
38794
  .navi_summary_marker {
38502
38795
  width: 1em;
38503
38796
  height: 1em;
@@ -38582,7 +38875,7 @@ const SummaryMarker = ({
38582
38875
  open,
38583
38876
  loading
38584
38877
  }) => {
38585
- 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"];
38586
38879
  const showLoading = useDebounceTrue(loading, 300);
38587
38880
  const mountedRef = useRef(false);
38588
38881
  const prevOpenRef = useRef(open);
@@ -38636,7 +38929,7 @@ const SummaryMarker = ({
38636
38929
  });
38637
38930
  };
38638
38931
 
38639
- installImportMetaCssBuild(import.meta);const css$H = /* css */`
38932
+ installImportMetaCssBuild(import.meta);const css$I = /* css */`
38640
38933
  .navi_details {
38641
38934
  position: relative;
38642
38935
  z-index: 1;
@@ -38682,7 +38975,7 @@ const Details = props => {
38682
38975
  return details;
38683
38976
  };
38684
38977
  const DetailsField = props => {
38685
- 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"];
38686
38979
  const {
38687
38980
  ref,
38688
38981
  persists,
@@ -38938,7 +39231,7 @@ const ControlGroup = props => {
38938
39231
  };
38939
39232
  const CONTROL_GROUP_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
38940
39233
 
38941
- installImportMetaCssBuild(import.meta);const css$G = /* css */`
39234
+ installImportMetaCssBuild(import.meta);const css$H = /* css */`
38942
39235
  @layer navi {
38943
39236
  .navi_checkbox {
38944
39237
  --switch-margin: 0; /* Useful to reserve space for outline */
@@ -39016,7 +39309,7 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39016
39309
  }
39017
39310
  `;
39018
39311
  const SwitchUI = () => {
39019
- 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"];
39020
39313
  return jsx(Box, {
39021
39314
  className: "navi_switch",
39022
39315
  as: "svg",
@@ -39058,7 +39351,7 @@ const useCheckableProps = (props, options) => {
39058
39351
  return result;
39059
39352
  };
39060
39353
 
39061
- installImportMetaCssBuild(import.meta);const css$F = /* css */`
39354
+ installImportMetaCssBuild(import.meta);const css$G = /* css */`
39062
39355
  @layer navi {
39063
39356
  .navi_checkbox {
39064
39357
  --border-radius: var(--navi-checkbox-border-radius);
@@ -39385,7 +39678,7 @@ const InputCheckboxHeadless = props => {
39385
39678
  });
39386
39679
  };
39387
39680
  const InputCheckboxFieldInterface = props => {
39388
- 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"];
39389
39682
  const [checkboxRootProps, checkboxHostProps] = useCheckableProps(props);
39390
39683
  const {
39391
39684
  icon,
@@ -39507,7 +39800,7 @@ const CheckboxButtonStyleCSSVars = {
39507
39800
  const CheckboxPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":checked", ":-navi-loading"];
39508
39801
  const CheckboxPseudoElements = ["::-navi-loader", "::-navi-checkmark"];
39509
39802
 
39510
- installImportMetaCssBuild(import.meta);const css$E = /* css */`
39803
+ installImportMetaCssBuild(import.meta);const css$F = /* css */`
39511
39804
  @layer navi {
39512
39805
  .navi_label {
39513
39806
  --label-required-indicator-color: var(--navi-color-danger, #b42318);
@@ -39587,7 +39880,7 @@ installImportMetaCssBuild(import.meta);const css$E = /* css */`
39587
39880
  * </Field>
39588
39881
  */
39589
39882
  const Field = props => {
39590
- import.meta.css = [css$E, "@jsenv/navi/src/control/field.jsx"];
39883
+ import.meta.css = [css$F, "@jsenv/navi/src/control/field.jsx"];
39591
39884
  const refDefault = useRef();
39592
39885
  props.ref = props.ref || refDefault;
39593
39886
  const {
@@ -39622,7 +39915,7 @@ const FieldCSSVars = {
39622
39915
  spacingWithControl: "--spacing-with-control"
39623
39916
  };
39624
39917
  const FieldAsContainer = props => {
39625
- import.meta.css = [css$E, "@jsenv/navi/src/control/field.jsx"];
39918
+ import.meta.css = [css$F, "@jsenv/navi/src/control/field.jsx"];
39626
39919
  const {
39627
39920
  children
39628
39921
  } = props;
@@ -39654,7 +39947,7 @@ const FieldAsContainer = props => {
39654
39947
  };
39655
39948
  const FIELD_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
39656
39949
  const Label = props => {
39657
- import.meta.css = [css$E, "@jsenv/navi/src/control/field.jsx"];
39950
+ import.meta.css = [css$F, "@jsenv/navi/src/control/field.jsx"];
39658
39951
  const {
39659
39952
  children,
39660
39953
  // Marks the label when its control is required. Takes what to show, or
@@ -39677,6 +39970,25 @@ const Label = props => {
39677
39970
  if (!Object.hasOwn(props, "htmlFor") && controlId) {
39678
39971
  props.htmlFor = controlId;
39679
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]);
39680
39992
  const [messageProps, remainingProps] = extractMessageAndRemainingProps({
39681
39993
  ...props,
39682
39994
  requiredIndicator: undefined
@@ -39797,7 +40109,7 @@ const InputSlot = ({
39797
40109
  });
39798
40110
  };
39799
40111
 
39800
- installImportMetaCssBuild(import.meta);const css$D = /* css */`
40112
+ installImportMetaCssBuild(import.meta);const css$E = /* css */`
39801
40113
  @layer navi {
39802
40114
  .navi_radio {
39803
40115
  --margin: 3px 3px 3px 5px;
@@ -40160,7 +40472,7 @@ const InputRadioHeadless = props => {
40160
40472
  };
40161
40473
  const APPEARANCE_SET = new Set(["icon", "button", "radio"]);
40162
40474
  const InputRadioFieldInterface = props => {
40163
- 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"];
40164
40476
  const [radioRootProps, radioHostProps] = useCheckableProps(props);
40165
40477
  const {
40166
40478
  icon,
@@ -40306,7 +40618,7 @@ const RadioButtonStyleCSSVars = {
40306
40618
  const RadioPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":checked", ":-navi-loading"];
40307
40619
  const RadioPseudoElements = ["::-navi-loader", "::-navi-radiomark"];
40308
40620
 
40309
- installImportMetaCssBuild(import.meta);const css$C = /* css */`
40621
+ installImportMetaCssBuild(import.meta);const css$D = /* css */`
40310
40622
  @layer navi {
40311
40623
  .navi_input_range {
40312
40624
  --border-radius: 6px;
@@ -40561,7 +40873,7 @@ const InputRange = props => {
40561
40873
  });
40562
40874
  };
40563
40875
  const InputRangeFieldInterface = props => {
40564
- 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"];
40565
40877
  const {
40566
40878
  ref
40567
40879
  } = props;
@@ -42488,7 +42800,7 @@ installImportMetaCssBuild(import.meta);/**
42488
42800
  * This means an editable thing MUST have a parent with position relative that wraps the content and the eventual editable input
42489
42801
  *
42490
42802
  */
42491
- const css$B = /* css */`
42803
+ const css$C = /* css */`
42492
42804
  .navi_editable_wrapper {
42493
42805
  --inset-top: 0px;
42494
42806
  --inset-right: 0px;
@@ -42537,7 +42849,7 @@ const useEditionController = () => {
42537
42849
  };
42538
42850
  };
42539
42851
  const Editable = props => {
42540
- 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"];
42541
42853
  let {
42542
42854
  children,
42543
42855
  action,
@@ -42767,6 +43079,7 @@ const useFormGroup = props => {
42767
43079
  // against the state of the previous frame.
42768
43080
  uiStateController.shouldRequestAction = value => Boolean(props.canSendWhileUnchanged) || !compareTwoJsValues(withoutEmptyFields(value), uiStateController.sentUIState);
42769
43081
  useFirstUIStateAsSent(uiStateController);
43082
+ useUnregisteredControlWarning(props.ref);
42770
43083
  const {
42771
43084
  basePseudoState,
42772
43085
  children
@@ -42915,6 +43228,15 @@ const useFirstUIStateAsSent = uiStateController => {
42915
43228
  uiStateController.sentUIState = readHeldUIState(uiStateController);
42916
43229
  }, [uiStateController]);
42917
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
+ };
42918
43240
  const FormPseudoClasses = [":hover", ":active", ":focus", ":focus-visible", ":read-only", ":disabled", ":-navi-loading"];
42919
43241
 
42920
43242
  // https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Constraint_validation
@@ -42951,7 +43273,7 @@ HTMLFormElement.prototype.requestSubmit = function (submitter) {
42951
43273
  // form.dispatchEvent(customEvent);
42952
43274
  // };
42953
43275
 
42954
- installImportMetaCssBuild(import.meta);const css$A = /* css */`
43276
+ installImportMetaCssBuild(import.meta);const css$B = /* css */`
42955
43277
  .navi_group {
42956
43278
  --group-border-width: 1px;
42957
43279
 
@@ -43047,7 +43369,7 @@ const Group = ({
43047
43369
  vertical = row,
43048
43370
  ...props
43049
43371
  }) => {
43050
- import.meta.css = [css$A, "@jsenv/navi/src/control/group.jsx"];
43372
+ import.meta.css = [css$B, "@jsenv/navi/src/control/group.jsx"];
43051
43373
  return jsx(Box, {
43052
43374
  baseClassName: "navi_group",
43053
43375
  "data-vertical": vertical ? "" : undefined,
@@ -43174,7 +43496,7 @@ installImportMetaCssBuild(import.meta);/**
43174
43496
  * pair. One popup holding slides of its own contents has no such problem — and
43175
43497
  * it is the same component in the document, in a dialog or in a popover.
43176
43498
  */
43177
- const css$z = /* css */`
43499
+ const css$A = /* css */`
43178
43500
  /* Every slide in the same grid cell: the box then measures itself on the
43179
43501
  LARGEST of them, in both directions, without anything being measured by
43180
43502
  hand — which is also why nothing here resizes as the slides change. Each
@@ -43484,7 +43806,7 @@ const SlideContainer = ({
43484
43806
  children,
43485
43807
  ...rest
43486
43808
  }) => {
43487
- 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"];
43488
43810
  const debugFocus = useDebugFocus();
43489
43811
  const trackRef = useRef();
43490
43812
  // The box itself: it is what takes the keyboard when what is on screen holds
@@ -45348,7 +45670,7 @@ installImportMetaCssBuild(import.meta);/**
45348
45670
  * pass through untouched via `...rest` to whichever of Popover/Dialog
45349
45671
  * actually renders.
45350
45672
  */
45351
- const css$y = /* css */`
45673
+ const css$z = /* css */`
45352
45674
  @layer navi {
45353
45675
  .navi_popup {
45354
45676
  --popup-border-radius: var(--navi-popup-border-radius);
@@ -45439,10 +45761,15 @@ const css$y = /* css */`
45439
45761
  * @param {string} [props.className] - Merged with the shared
45440
45762
  * `"navi_popup"` class (see this file's own CSS) rather than replacing
45441
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.
45442
45769
  * @param {import("ignore:preact").ComponentChildren} props.children
45443
45770
  */
45444
45771
  const Popup = props => {
45445
- import.meta.css = [css$y, "@jsenv/navi/src/layout/popup.jsx"];
45772
+ import.meta.css = [css$z, "@jsenv/navi/src/layout/popup.jsx"];
45446
45773
  const {
45447
45774
  mode: modeProp,
45448
45775
  maxWidth,
@@ -45585,7 +45912,7 @@ const commitSubtree = (controller, e) => {
45585
45912
  }
45586
45913
  };
45587
45914
 
45588
- installImportMetaCssBuild(import.meta);const css$x = /* css */`
45915
+ installImportMetaCssBuild(import.meta);const css$y = /* css */`
45589
45916
  .navi_picker {
45590
45917
  /* Sizing ceilings (maxmax), background, box-shadow, outline, padding,
45591
45918
  overflow... are already handled correctly by Popup/Popover/Dialog
@@ -45697,7 +46024,7 @@ installImportMetaCssBuild(import.meta);const css$x = /* css */`
45697
46024
  }
45698
46025
  `;
45699
46026
  const PickerCustomResolver = props => {
45700
- 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"];
45701
46028
  if (props.children === undefined) {
45702
46029
  return jsx(PickerNative, {
45703
46030
  ...props
@@ -45963,6 +46290,13 @@ const PickerCustom = props => {
45963
46290
  Object.assign(popupProps, {
45964
46291
  anchor: props.ref,
45965
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"),
45966
46300
  // Not on pickerProps (the trigger): commands.js's own
45967
46301
  // resolveClosestExpandable() does `el.closest("[aria-expanded]")` to
45968
46302
  // find where to dispatch navi_request_open/navi_request_close — and
@@ -46342,7 +46676,7 @@ const LoadingIndicator = ({
46342
46676
  });
46343
46677
  };
46344
46678
 
46345
- installImportMetaCssBuild(import.meta);const css$w = /* css */`
46679
+ installImportMetaCssBuild(import.meta);const css$x = /* css */`
46346
46680
  @layer navi {
46347
46681
  .navi_separator {
46348
46682
  --size: 1px;
@@ -46420,7 +46754,7 @@ const Separator = ({
46420
46754
  style,
46421
46755
  ...props
46422
46756
  }) => {
46423
- import.meta.css = [css$w, "@jsenv/navi/src/layout/separator.jsx"];
46757
+ import.meta.css = [css$x, "@jsenv/navi/src/layout/separator.jsx"];
46424
46758
  return jsx(Box, {
46425
46759
  as: vertical ? "span" : "hr",
46426
46760
  ...props,
@@ -46913,7 +47247,7 @@ const ListItemFooter = props => {
46913
47247
  });
46914
47248
  };
46915
47249
 
46916
- installImportMetaCssBuild(import.meta);const css$v = /* css */`
47250
+ installImportMetaCssBuild(import.meta);const css$w = /* css */`
46917
47251
  @layer navi {
46918
47252
  .navi_list_container[navi-selectable] {
46919
47253
  /* Focus outline */
@@ -47117,7 +47451,7 @@ const ListSelectableResolver = props => {
47117
47451
  };
47118
47452
  const ListSelectable = props => {
47119
47453
  const Next = useNextResolver();
47120
- 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"];
47121
47455
  // we allow ourselves to auto-generate a name
47122
47456
  const defaultName = useId();
47123
47457
  props.name = props.name || `listbox_${defaultName}`;
@@ -47712,7 +48046,7 @@ const ListVirtualContext = createContext(null);
47712
48046
  // that returning a component of one's own — instead of a bare <List.Item> —
47713
48047
  // works the same way.
47714
48048
  const ListRowContext = createContext(null);
47715
- const css$u = /* css */`
48049
+ const css$v = /* css */`
47716
48050
  @layer navi {
47717
48051
  .navi_list_container {
47718
48052
  --list-outline-width: 1px;
@@ -48237,7 +48571,7 @@ const css$u = /* css */`
48237
48571
  }
48238
48572
  `;
48239
48573
  const ListUI = props => {
48240
- 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"];
48241
48575
  const {
48242
48576
  ref,
48243
48577
  renderBudget: renderBudgetProp = RENDER_BUDGET_DEFAULT,
@@ -51340,7 +51674,7 @@ const PickerPresetResolver = props => {
51340
51674
  });
51341
51675
  };
51342
51676
 
51343
- installImportMetaCssBuild(import.meta);const css$t = /* css */`
51677
+ installImportMetaCssBuild(import.meta);const css$u = /* css */`
51344
51678
  @layer navi {
51345
51679
  }
51346
51680
  .navi_badge {
@@ -51452,7 +51786,7 @@ const Badge = ({
51452
51786
  className,
51453
51787
  ...props
51454
51788
  }) => {
51455
- import.meta.css = [css$t, "@jsenv/navi/src/text/badge.jsx"];
51789
+ import.meta.css = [css$u, "@jsenv/navi/src/text/badge.jsx"];
51456
51790
  const defaultRef = useRef();
51457
51791
  props.ref = props.ref || defaultRef;
51458
51792
  const {
@@ -51504,7 +51838,7 @@ const BadgeButton = props => {
51504
51838
  };
51505
51839
  Badge.Button = BadgeButton;
51506
51840
 
51507
- installImportMetaCssBuild(import.meta);const css$s = /* css */`
51841
+ installImportMetaCssBuild(import.meta);const css$t = /* css */`
51508
51842
  @layer navi {
51509
51843
  }
51510
51844
  .navi_badge_list {
@@ -51529,7 +51863,7 @@ const BadgeList = ({
51529
51863
  max,
51530
51864
  ...props
51531
51865
  }) => {
51532
- 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"];
51533
51867
  const measureRef = useRef();
51534
51868
  const visibleRef = useRef();
51535
51869
  useLayoutEffect(() => {
@@ -51604,7 +51938,7 @@ const BadgeList = ({
51604
51938
  });
51605
51939
  };
51606
51940
 
51607
- installImportMetaCssBuild(import.meta);const css$r = /* css */`
51941
+ installImportMetaCssBuild(import.meta);const css$s = /* css */`
51608
51942
  .navi_color {
51609
51943
  display: block;
51610
51944
  aspect-ratio: 1/1;
@@ -51635,7 +51969,7 @@ const Color = ({
51635
51969
  children,
51636
51970
  ...rest
51637
51971
  }) => {
51638
- import.meta.css = [css$r, "@jsenv/navi/src/text/color.jsx"];
51972
+ import.meta.css = [css$s, "@jsenv/navi/src/text/color.jsx"];
51639
51973
  const color = children || undefined;
51640
51974
  return jsx(Box, {
51641
51975
  as: "span",
@@ -52090,7 +52424,7 @@ const PickerFileUI = () => {
52090
52424
  return String(value);
52091
52425
  };
52092
52426
 
52093
- installImportMetaCssBuild(import.meta);const css$q = /* css */`
52427
+ installImportMetaCssBuild(import.meta);const css$r = /* css */`
52094
52428
  @layer navi {
52095
52429
  .navi_picker {
52096
52430
  --picker-border-radius: var(--navi-control-border-radius);
@@ -52416,7 +52750,7 @@ installImportMetaCssBuild(import.meta);const css$q = /* css */`
52416
52750
  }
52417
52751
  `;
52418
52752
  const PickerButton = props => {
52419
- 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"];
52420
52754
  if (typeof props.maxLines === "string") {
52421
52755
  props.maxLines = parseInt(props.maxLines);
52422
52756
  }
@@ -52879,7 +53213,7 @@ installImportMetaCssBuild(import.meta);/**
52879
53213
  * refuse it on purpose, which is what keeps the focus where the travel happens
52880
53214
  * instead of moving it into a slide that is about to leave.
52881
53215
  */
52882
- const css$p = /* css */`
53216
+ const css$q = /* css */`
52883
53217
  @layer navi {
52884
53218
  .navi_picker_spin {
52885
53219
  /* A picker one steps through is still a picker: what themes every picker
@@ -53225,7 +53559,7 @@ const Spin = ({
53225
53559
  nextLabel,
53226
53560
  ...rest
53227
53561
  }) => {
53228
- 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"];
53229
53563
  const id = useId();
53230
53564
  const containerId = `${id}_values`;
53231
53565
  const controlId = `${id}_control`;
@@ -53235,7 +53569,14 @@ const Spin = ({
53235
53569
  // and this reads the answer back. Nothing of that story is told twice.
53236
53570
  const controlRef = useRef();
53237
53571
  const middleRef = useRef();
53238
- const valueShown = useControlUIState(controlRef, value ?? defaultValue ?? signalProp?.peek()) ?? fallbackValue;
53572
+ const valueHeld = useControlUIState(controlRef, value ?? defaultValue ?? signalProp?.peek());
53573
+ // A spin always shows a value, and empty is not one: the calendar has an
53574
+ // "erase" in it, and what comes back from it is "" rather than nothing —
53575
+ // which is a value as far as `??` is concerned and is not one as far as
53576
+ // `valueAtStep` is (a day made of an empty string is an invalid date, and
53577
+ // stepping away from it writes NaN into the control). So the fallback is
53578
+ // asked for here, once, for every kind of value a spin steps through.
53579
+ const valueShown = valueHeld === undefined || valueHeld === null || valueHeld === "" ? fallbackValue : valueHeld;
53239
53580
 
53240
53581
  // …and the other way round when a signal was handed over: a bound signal is
53241
53582
  // the value, wherever it is moved from — the url, a back/forward, a button
@@ -53836,7 +54177,7 @@ const addDays = (day, count) => {
53836
54177
  };
53837
54178
 
53838
54179
  installImportMetaCssBuild(import.meta);// TOFIX: select in data then reset, it reset to red/blue instead of red/blue/green
53839
- const css$o = /* css */`
54180
+ const css$p = /* css */`
53840
54181
  .navi_checkbox_group {
53841
54182
  border-style: solid;
53842
54183
 
@@ -53856,7 +54197,7 @@ const CheckboxGroup = props => {
53856
54197
  return checkboxGroup;
53857
54198
  };
53858
54199
  const CheckboxGroupInterface = props => {
53859
- import.meta.css = [css$o, "@jsenv/navi/src/control/input/checkbox_group.jsx"];
54200
+ import.meta.css = [css$p, "@jsenv/navi/src/control/input/checkbox_group.jsx"];
53860
54201
  const {
53861
54202
  ref
53862
54203
  } = props;
@@ -53905,7 +54246,7 @@ installImportMetaCssBuild(import.meta);/**
53905
54246
  * shared sheet is registered here too — a page may render a Textarea without
53906
54247
  * any Input.
53907
54248
  */
53908
- const css$n = /* css */`
54249
+ const css$o = /* css */`
53909
54250
  .navi_input.navi_textarea {
53910
54251
  .navi_control_input {
53911
54252
  min-height: calc(var(--textarea-min-rows, 1.5) * 1lh);
@@ -53992,7 +54333,7 @@ const Textarea = ({
53992
54333
  width = "35ch",
53993
54334
  ...props
53994
54335
  }) => {
53995
- import.meta.css = [inputCss + css$n, "@jsenv/navi/src/control/input/textarea.jsx"];
54336
+ import.meta.css = [inputCss + css$o, "@jsenv/navi/src/control/input/textarea.jsx"];
53996
54337
  const defaultRef = useRef(null);
53997
54338
  props.ref = props.ref || defaultRef;
53998
54339
  usePlaceholderHeight(props.ref, props.placeholder);
@@ -54066,7 +54407,7 @@ const TextareaCharCount = ({
54066
54407
  maxLength,
54067
54408
  ...rest
54068
54409
  }) => {
54069
- import.meta.css = [css$n, "@jsenv/navi/src/control/input/textarea.jsx"];
54410
+ import.meta.css = [css$o, "@jsenv/navi/src/control/input/textarea.jsx"];
54070
54411
  const resolvedValue = signal ? signal.value : value;
54071
54412
  const length = typeof resolvedValue === "string" ? resolvedValue.length : 0;
54072
54413
  return jsx(Box, {
@@ -54456,7 +54797,7 @@ const isTextInputElement = (el) => {
54456
54797
  );
54457
54798
  };
54458
54799
 
54459
- installImportMetaCssBuild(import.meta);const css$m = /* css */`
54800
+ installImportMetaCssBuild(import.meta);const css$n = /* css */`
54460
54801
  .navi_input_duration {
54461
54802
  --duration-separator-spacing: 4px;
54462
54803
  --loader-color: var(--navi-loader-color);
@@ -54523,7 +54864,7 @@ installImportMetaCssBuild(import.meta);const css$m = /* css */`
54523
54864
  * "auto" aligns each field toward its neighbouring separator (first→right, last→left, middle/solo→center).
54524
54865
  */
54525
54866
  const InputDuration = props => {
54526
- import.meta.css = [css$m, "@jsenv/navi/src/control/input/input_duration.jsx"];
54867
+ import.meta.css = [css$n, "@jsenv/navi/src/control/input/input_duration.jsx"];
54527
54868
  const defaultRef = useRef();
54528
54869
  props.ref = props.ref || defaultRef;
54529
54870
  props.max = props.max || "23h59";
@@ -55025,7 +55366,7 @@ const InputDurationPart = ({
55025
55366
  });
55026
55367
  };
55027
55368
 
55028
- installImportMetaCssBuild(import.meta);const css$l = /* css */`
55369
+ installImportMetaCssBuild(import.meta);const css$m = /* css */`
55029
55370
  .navi_radio_group {
55030
55371
  border-style: solid;
55031
55372
 
@@ -55045,7 +55386,7 @@ const RadioGroup = props => {
55045
55386
  return radioGroup;
55046
55387
  };
55047
55388
  const RadioGroupInterface = props => {
55048
- import.meta.css = [css$l, "@jsenv/navi/src/control/input/radio_group.jsx"];
55389
+ import.meta.css = [css$m, "@jsenv/navi/src/control/input/radio_group.jsx"];
55049
55390
  const {
55050
55391
  ref
55051
55392
  } = props;
@@ -55074,6 +55415,139 @@ const RadioGroupInterface = props => {
55074
55415
  });
55075
55416
  };
55076
55417
 
55418
+ installImportMetaCssBuild(import.meta);/**
55419
+ * A native `<select>` that is a navi control: its value enters the state of the
55420
+ * `<Form>` around it, and it takes `signal`, `uiAction`, `action`, `command`,
55421
+ * `value`/`defaultValue`, `readOnly`, `disabled`, `required` like every other
55422
+ * control.
55423
+ *
55424
+ * Native on purpose, and not a Picker: on a phone a `<select>` opens the
55425
+ * system's own full-screen list — the thing the thumb handles best and the user
55426
+ * already knows — with no popup, no positioning and no focus trap to get wrong.
55427
+ * That is the right control for a short closed list (a gender, an age bracket,
55428
+ * "who sees this"). A long, searchable list with rich content in its options is
55429
+ * Picker's problem, not this one.
55430
+ *
55431
+ * The options are the children, written as HTML: an `<optgroup>`, a `disabled`
55432
+ * option, an `<hr>` between two groups need no support from this component.
55433
+ *
55434
+ * Two things the component absorbs, both traps met before:
55435
+ * - a native `<select>` ignores `defaultValue` and reads `selected` off its
55436
+ * options instead; here `value`/`defaultValue` mean what they mean everywhere
55437
+ * else in navi, and the element is told what to show.
55438
+ * - a native `<select>` has no `readonly`; `readOnly` here refuses the
55439
+ * interaction (the list does not open, a change from the keyboard is put
55440
+ * back) and says so with `aria-readonly`.
55441
+ *
55442
+ * Styled as a `.navi_input` box so a select and an input sitting next to each
55443
+ * other are the same box. `appearance: none` only changes how the closed
55444
+ * control is drawn — the list it opens stays the platform's own, which is the
55445
+ * whole point of using a select.
55446
+ */
55447
+ const css$l = /* css */`
55448
+ .navi_input.navi_select {
55449
+ .navi_control_input {
55450
+ /* Room for the chevron, which sits over the padding rather than beside
55451
+ the control — anything beside it would be a click that misses. */
55452
+ padding-right: calc(var(--x-padding-right) + 1em);
55453
+ /* A form control keeps a line of its own whatever the page is written in,
55454
+ and lh units elsewhere in the box are resolved against a real number. */
55455
+ line-height: normal;
55456
+ /* The closed control is drawn by us so it matches the other fields; the
55457
+ list it opens is untouched and stays the system's. */
55458
+ appearance: none;
55459
+ cursor: pointer;
55460
+ }
55461
+ &[data-readonly] .navi_control_input,
55462
+ &[data-disabled] .navi_control_input {
55463
+ cursor: inherit;
55464
+ }
55465
+
55466
+ .navi_select_arrow {
55467
+ position: absolute;
55468
+ top: 50%;
55469
+ right: var(--x-padding-right);
55470
+ display: flex;
55471
+ color: var(--color-dimmed);
55472
+ translate: 0 -50%;
55473
+ /* The arrow is drawn on top of the control it belongs to: a click on it
55474
+ must reach the select and open the list. */
55475
+ pointer-events: none;
55476
+ }
55477
+ }
55478
+ `;
55479
+
55480
+ /**
55481
+ * @type {import("ignore:preact").FunctionComponent<{
55482
+ * value?: string,
55483
+ * defaultValue?: string,
55484
+ * signal?: import("@preact/signals").Signal<string>,
55485
+ * name?: string,
55486
+ * width?: string,
55487
+ * [key: string]: any,
55488
+ * }>}
55489
+ * @param {string} [value] The choice the control is GIVEN — what is already
55490
+ * saved. A form holding it considers that field as already sent.
55491
+ * @param {string} [defaultValue] The choice the control PROPOSES, and what a
55492
+ * reset goes back to. Sending it back is an answer, so a form counts it as
55493
+ * something to send.
55494
+ * @param {string} [width] The control's width. Left out, the box takes the
55495
+ * width of its widest option.
55496
+ */
55497
+ const Select = ({
55498
+ width,
55499
+ multiple,
55500
+ ...props
55501
+ }) => {
55502
+ import.meta.css = [inputCss + css$l, "@jsenv/navi/src/control/input/select.jsx"];
55503
+ const defaultRef = useRef(null);
55504
+ props.ref = props.ref || defaultRef;
55505
+ seedDefaultValueFromSignal(props);
55506
+ const [rootProps, hostProps] = useControlProps(props, {
55507
+ controlType: "select"
55508
+ });
55509
+ const {
55510
+ basePseudoState
55511
+ } = hostProps;
55512
+ // `type` on a <select> is the browser's own read-only "select-one".
55513
+ delete hostProps.type;
55514
+ const loading = basePseudoState[":-navi-loading"];
55515
+ if (width !== undefined) {
55516
+ // On the select, not on the box around it: the box is fit-content and the
55517
+ // control is what has a width to give.
55518
+ hostProps.width = width;
55519
+ }
55520
+ return jsxs(Box, {
55521
+ as: "span",
55522
+ inline: true,
55523
+ flex: true,
55524
+ baseClassName: "navi_input",
55525
+ className: "navi_select",
55526
+ ...rootProps,
55527
+ basePseudoState: basePseudoState,
55528
+ styleCSSVars: InputStyleCSSVars,
55529
+ pseudoStateSelector: ".navi_control_input",
55530
+ pseudoClasses: InputPseudoClasses,
55531
+ pseudoElements: InputPseudoElements,
55532
+ "data-callout-anchor": ".navi_control_input",
55533
+ children: [jsx(LoadingOutline, {
55534
+ loading: loading,
55535
+ color: "var(--loader-color)",
55536
+ inset: -1
55537
+ }), jsx(Box, {
55538
+ ...hostProps,
55539
+ as: "select",
55540
+ baseClassName: "navi_control_input"
55541
+ }), jsx("span", {
55542
+ className: "navi_select_arrow",
55543
+ children: jsx(Icon, {
55544
+ lineOverflow: "allow",
55545
+ children: jsx(ChevronDownSvg$1, {})
55546
+ })
55547
+ })]
55548
+ });
55549
+ };
55550
+
55077
55551
  /**
55078
55552
  * applySearch — matches value against searchText.
55079
55553
  *
@@ -63656,5 +64130,5 @@ const UserSvg = () => jsx("svg", {
63656
64130
  })
63657
64131
  });
63658
64132
 
63659
- 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 };
64133
+ 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 };
63660
64134
  //# sourceMappingURL=jsenv_navi.js.map