@jsenv/navi 0.29.103 → 0.29.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, mergeTwoStyles, normalizeStyles, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, clickIsSuppressed, isTouchDrivenEvent, scrollIntoViewScoped, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, stringifyStyle as stringifyStyle$1, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { elementIsFocusable, createPubSub, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createIterableWeakSet, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, resolveCSSSize, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, hasCSSSizeUnit, resolveOklchLightness, contrastColor, clickIsSuppressed, isTouchDrivenEvent, scrollIntoViewScoped, scrollRoomTowards, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, findBefore, findAfter, initFocusGroup, stringifyStyle as stringifyStyle$1, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
9
  import { signal, computed, effect, batch, untracked, useSignal } from "@preact/signals";
10
10
  import { createContext, isValidElement, h, Fragment, render, toChildArray, options, cloneElement } from "preact";
@@ -38,7 +38,7 @@ installImportMetaCssBuild(import.meta);/**
38
38
  * any of these, and a number is the last resort, not the first tool.
39
39
  */
40
40
 
41
- const css$15 = /* css */`
41
+ const css$14 = /* css */`
42
42
  @layer navi {
43
43
  :root {
44
44
  /* A control that overlaps its neighbours (the members of a Group share
@@ -102,7 +102,7 @@ const css$15 = /* css */`
102
102
  }
103
103
  }
104
104
  `;
105
- import.meta.css = [css$15, "@jsenv/navi/src/navi_z_indexes.js"];
105
+ import.meta.css = [css$14, "@jsenv/navi/src/navi_z_indexes.js"];
106
106
 
107
107
  const addIntoArray = (array, ...valuesToAdd) => {
108
108
  if (valuesToAdd.length === 1) {
@@ -371,7 +371,7 @@ installImportMetaCssBuild(import.meta);/**
371
371
  * the very first render and the browser does everything on its own.
372
372
  */
373
373
  const URL_TARGET_ATTRIBUTE = "data-url-target";
374
- const css$14 = /* css */`
374
+ const css$13 = /* css */`
375
375
  @layer navi {
376
376
  [${URL_TARGET_ATTRIBUTE}] {
377
377
  animation: navi_url_target var(--navi-url-target-duration, 2000ms)
@@ -389,7 +389,7 @@ const css$14 = /* css */`
389
389
  }
390
390
  }
391
391
  `;
392
- import.meta.css = [css$14, "@jsenv/navi/src/nav/url_target/url_target.js"];
392
+ import.meta.css = [css$13, "@jsenv/navi/src/nav/url_target/url_target.js"];
393
393
  let urlTargetOptions = {
394
394
  block: "start",
395
395
  behavior: "instant",
@@ -2335,17 +2335,37 @@ const ActionRequesterContext = createContext();
2335
2335
  * one wrong finds out by opening a popup it meant to keep shut. `ownTarget` is
2336
2336
  * that knowledge, said once.
2337
2337
  *
2338
- * The other half is interactivity: the affordance is a control, so it already
2339
- * refuses on its own terms once the zone around it holds it read-only — but a
2340
- * caller's `onClick` is plain DOM and fires before any gate. So an own target
2341
- * withholds the caller's handler until its own gate has allowed it, and by
2342
- * default goes rather than greys: a remove cross that still removes is worse
2343
- * than no cross, and one that refuses politely still says "there is something to
2344
- * remove here" on a row that is only being read.
2338
+ * The other half is interactivity, and the question that settles it is: **does
2339
+ * this affordance write to the control it sits in?**
2340
+ *
2341
+ * - it does, and once the value cannot be changed it has nothing left to offer:
2342
+ * it GOES. A remove cross that still removes is worse than no cross, and one
2343
+ * that refuses politely still says "there is something to remove here" on a
2344
+ * row that is only being read. That is the default.
2345
+ * - it does, but its presence is information in itself: `"refuse"` keeps it and
2346
+ * refuses in its own words, like every other navi control.
2347
+ * - it does NOT — a diskette saving a row into the reader's own address book, a
2348
+ * badge explaining why a placement is odd. The read-only around it is about a
2349
+ * value it never touches, so `"always"` ignores it: the affordance stays lit
2350
+ * and stays pressable. It is on the caller to use it only for a gesture that
2351
+ * genuinely writes nothing to the control around it.
2352
+ *
2353
+ * Whichever mode, the affordance's own handler runs from inside its own gate
2354
+ * rather than from the DOM: a caller's `onClick` fires before any of this, and
2355
+ * would go off from a button drawn greyed.
2345
2356
  */
2346
2357
 
2347
2358
 
2348
- const OWN_TARGET_ATTRIBUTE = "data-navi-own-target";
2359
+ /**
2360
+ * The whole claim, in the DOM, on any element — a `<button>` an application
2361
+ * draws itself as much as a navi control. It is read from the outside and
2362
+ * nowhere else: by the controls above (below), and by the gesture readers
2363
+ * (@jsenv/dom's DRAG_EXCLUDED_SELECTOR and DRAG_IGNORED_SELECTOR), which is why
2364
+ * one attribute is enough and the `ownTarget` prop only writes it.
2365
+ *
2366
+ * Its value is the mode, when there is one to say: `data-own-target="always"`.
2367
+ */
2368
+ const OWN_TARGET_ATTRIBUTE = "data-own-target";
2349
2369
 
2350
2370
  /**
2351
2371
  * Whether `event` was aimed at an own target sitting below this control — in
@@ -2354,7 +2374,7 @@ const OWN_TARGET_ATTRIBUTE = "data-navi-own-target";
2354
2374
  * Read from the event's target rather than from a mark left by a handler: the
2355
2375
  * question is "who is this press for", and the DOM between the pointer and the
2356
2376
  * control is the whole answer. Nothing is asked of the own target itself, which
2357
- * is what lets it be anything — a button, a link, a field.
2377
+ * is what lets it be anything — a button, a link, a field, a bare element.
2358
2378
  */
2359
2379
  const isAimedAtOwnTargetBelow = (event, controlHost) => {
2360
2380
  const target = event?.target;
@@ -2378,19 +2398,25 @@ const isAimedAtOwnTargetBelow = (event, controlHost) => {
2378
2398
  };
2379
2399
 
2380
2400
  /**
2381
- * Whether an own target has nothing to offer where it sits: the zone around it
2382
- * is read-only, disabled or busy, so the affordance goes.
2383
- *
2384
- * `ownTarget="refuse"` keeps it on screen instead, refusing with a callout like
2385
- * every other navi control — for an affordance whose presence is information in
2386
- * itself (an eye that opens a profile is worth seeing on a row being read).
2401
+ * Whether the read-only, disabled and busy of the zone around this own target
2402
+ * are about it at all see the modes at the top of this file.
2403
+ */
2404
+ const ownTargetIgnoresZoneState = (ownTarget) => ownTarget === "always";
2405
+
2406
+ /**
2407
+ * Whether an own target has nothing to offer where it sits: it writes to the
2408
+ * control around it, and that control cannot be changed, so the affordance goes.
2387
2409
  */
2388
2410
  const useOwnTargetHidden = (props) => {
2389
2411
  const disabled = useContext(DisabledContext);
2390
2412
  const readOnly = useContext(ReadOnlyContext);
2391
2413
  const loading = useContext(LoadingContext$1);
2392
2414
  const { ownTarget } = props;
2393
- if (!ownTarget || ownTarget === "refuse") {
2415
+ if (
2416
+ !ownTarget ||
2417
+ ownTarget === "refuse" ||
2418
+ ownTargetIgnoresZoneState(ownTarget)
2419
+ ) {
2394
2420
  return false;
2395
2421
  }
2396
2422
  return Boolean(disabled || readOnly || loading);
@@ -2404,9 +2430,13 @@ const useOwnTargetHidden = (props) => {
2404
2430
  *
2405
2431
  * Beyond recursive object/array comparison it covers the edge cases `===` gets
2406
2432
  * "wrong" for equality purposes: NaN equals NaN, Date compared by time value,
2407
- * cycles don't loop (a seen-set guards circular refs), and same-type is required
2408
- * before descending. Cheap paths run first: reference equality, then the identity
2409
- * short-circuit below, then array length before element-by-element.
2433
+ * cycles don't loop (a set of the pairs being compared guards circular refs),
2434
+ * and same-type is required
2435
+ * before descending. Functions, and objects with nothing enumerable to compare
2436
+ * (a Set, a Map, an element, a URL), are equal by reference only — nothing in
2437
+ * them says whether two are "the same". Cheap paths run first: reference
2438
+ * equality, then the identity short-circuit below, then array length before
2439
+ * element-by-element.
2410
2440
  *
2411
2441
  * SYMBOL_IDENTITY. Two *different* object instances can be declared "conceptually
2412
2442
  * the same" by sharing a SYMBOL_IDENTITY value; the comparison then treats them as
@@ -2468,6 +2498,11 @@ const compareTwoJsValues = (
2468
2498
  if (aType !== bType) {
2469
2499
  return false;
2470
2500
  }
2501
+ if (aType === "function") {
2502
+ // Not the same function (reference equality came first), and nothing in
2503
+ // a function says whether two of them do the same thing.
2504
+ return false;
2505
+ }
2471
2506
  const aIsPrimitive =
2472
2507
  a === null || (aType !== "object" && aType !== "function");
2473
2508
  const bIsPrimitive =
@@ -2478,14 +2513,22 @@ const compareTwoJsValues = (
2478
2513
  if (aIsPrimitive && bIsPrimitive) {
2479
2514
  return a === b;
2480
2515
  }
2481
- if (seenSet.has(a)) {
2482
- return false;
2483
- }
2484
- if (seenSet.has(b)) {
2516
+ // Back on something still being compared: a cycle. No loop, and no answer
2517
+ // either — equal by a route that never ends is not equal.
2518
+ if (seenSet.has(a) || seenSet.has(b)) {
2485
2519
  return false;
2486
2520
  }
2521
+ // Held only while a and b are being compared, not for the rest of the
2522
+ // walk: the same object is rightly met again elsewhere — in an unordered
2523
+ // array every element of a is tried against every element of b.
2487
2524
  seenSet.add(a);
2488
2525
  seenSet.add(b);
2526
+ const result = compareComposite(a, b);
2527
+ seenSet.delete(a);
2528
+ seenSet.delete(b);
2529
+ return result;
2530
+ };
2531
+ const compareComposite = (a, b) => {
2489
2532
  const aIsArray = Array.isArray(a);
2490
2533
  const bIsArray = Array.isArray(b);
2491
2534
  if (aIsArray !== bIsArray) {
@@ -2543,25 +2586,25 @@ const compareTwoJsValues = (
2543
2586
  return true;
2544
2587
  }
2545
2588
  // Date objects must be compared by time value, not by enumerable keys (which are empty)
2546
- {
2547
- const aIsDate = a instanceof Date;
2548
- const bIsDate = b instanceof Date;
2549
- if (aIsDate !== bIsDate) {
2550
- return false;
2551
- }
2552
- if (aIsDate && bIsDate) {
2553
- const aTime = a.getTime();
2554
- const bTime = b.getTime();
2555
- if (aTime !== bTime) {
2556
- return false;
2557
- }
2558
- }
2589
+ const aIsDate = a instanceof Date;
2590
+ const bIsDate = b instanceof Date;
2591
+ if (aIsDate !== bIsDate) {
2592
+ return false;
2593
+ }
2594
+ if (aIsDate) {
2595
+ return a.getTime() === b.getTime();
2559
2596
  }
2560
2597
  const aKeys = Object.keys(a);
2561
2598
  const bKeys = Object.keys(b);
2562
2599
  if (aKeys.length !== bKeys.length) {
2563
2600
  return false;
2564
2601
  }
2602
+ if (aKeys.length === 0 && (!isPlainObject$2(a) || !isPlainObject$2(b))) {
2603
+ // A Set, a Map, an element, a URL: nothing enumerable to tell two of them
2604
+ // apart, so they are the same one or they are not — and they are not,
2605
+ // reference equality came first.
2606
+ return false;
2607
+ }
2565
2608
  if (lightKeySet) {
2566
2609
  // compare light keys first, then remaining keys
2567
2610
  // (optimization for cases where some keys are more likely to differ and/or faster to compare)
@@ -2599,6 +2642,11 @@ const compareTwoJsValues = (
2599
2642
  return compare(rootA, rootB);
2600
2643
  };
2601
2644
 
2645
+ const isPlainObject$2 = (value) => {
2646
+ const prototype = Object.getPrototypeOf(value);
2647
+ return prototype === Object.prototype || prototype === null;
2648
+ };
2649
+
2602
2650
  // Global signal registry for route template detection
2603
2651
  const globalSignalRegistry = new Map();
2604
2652
  let signalIdCounter = 0;
@@ -6861,6 +6909,10 @@ const findControlProxy = (el) => {
6861
6909
  return firstProxy;
6862
6910
  };
6863
6911
 
6912
+ let renderMessageText = text => text;
6913
+ const setCalloutMessageTextRenderer = renderer => {
6914
+ renderMessageText = renderer;
6915
+ };
6864
6916
  const CalloutRequestCloseContext = createContext();
6865
6917
  const useCalloutRequestClose = () => {
6866
6918
  return useContext(CalloutRequestCloseContext);
@@ -6875,6 +6927,48 @@ const renderIntoCallout = (jsx$1, calloutMessageElement, {
6875
6927
  render(calloutJsx, calloutMessageElement);
6876
6928
  };
6877
6929
 
6930
+ // An HTML message is rendered through preact rather than innerHTML so that its
6931
+ // text can go through renderMessageText: an emoji in a validation message must
6932
+ // not make the first line taller than the icon and close button beside it.
6933
+ const renderHtmlIntoCallout = (html, calloutMessageElement, {
6934
+ requestClose
6935
+ }) => {
6936
+ const template = document.createElement("template");
6937
+ template.innerHTML = html;
6938
+ renderIntoCallout(domToVNodes(template.content), calloutMessageElement, {
6939
+ requestClose
6940
+ });
6941
+ };
6942
+
6943
+ // Unmounts whatever preact rendered before the element is filled by hand
6944
+ // (a DOM node, an iframe); innerHTML alone would leave preact believing its
6945
+ // tree is still there.
6946
+ const clearCalloutMessage = calloutMessageElement => {
6947
+ render(null, calloutMessageElement);
6948
+ calloutMessageElement.innerHTML = "";
6949
+ };
6950
+ const domToVNodes = node => {
6951
+ const vnodes = [];
6952
+ for (const child of node.childNodes) {
6953
+ if (child.nodeType === Node.TEXT_NODE) {
6954
+ vnodes.push(renderMessageText(child.data));
6955
+ continue;
6956
+ }
6957
+ if (child.nodeType !== Node.ELEMENT_NODE) {
6958
+ continue;
6959
+ }
6960
+ const props = {};
6961
+ for (const {
6962
+ name,
6963
+ value
6964
+ } of child.attributes) {
6965
+ props[name] = value;
6966
+ }
6967
+ vnodes.push(h(child.localName, props, ...domToVNodes(child)));
6968
+ }
6969
+ return vnodes;
6970
+ };
6971
+
6878
6972
  installImportMetaCssBuild(import.meta);/**
6879
6973
  * A callout component that mimics native browser validation messages.
6880
6974
  * Features:
@@ -6884,7 +6978,7 @@ installImportMetaCssBuild(import.meta);/**
6884
6978
  * - Arrow automatically shows when pointing at a valid anchor element
6885
6979
  * - Centers in viewport when no anchor element provided or anchor is too big
6886
6980
  */
6887
- const css$13 = /* css */`
6981
+ const css$12 = /* css */`
6888
6982
  @layer navi {
6889
6983
  .navi_callout {
6890
6984
  /* A callout is parented to what it explains, so it inherits from it — and
@@ -6986,7 +7080,10 @@ const css$13 = /* css */`
6986
7080
  }
6987
7081
 
6988
7082
  .navi_callout_body {
6989
- --callout-icon-height: round(1.5em, 1px);
7083
+ /* The first line of the message: the icon and the close button sit
7084
+ in columns of that height, pinned to the top, so both stay level
7085
+ with it however many lines the message takes. */
7086
+ --callout-icon-height: round(1lh, 1px);
6990
7087
 
6991
7088
  position: relative;
6992
7089
  display: flex;
@@ -7042,10 +7139,15 @@ const css$13 = /* css */`
7042
7139
  align-self: flex-start;
7043
7140
 
7044
7141
  .navi_callout_close_button {
7045
- width: 1em;
7046
- height: 1em;
7047
- padding: 0;
7048
- align-self: center;
7142
+ /* A square filling the column, so the whole first line is the target;
7143
+ the padding keeps the cross itself at glyph size. */
7144
+ display: inline-flex;
7145
+ box-sizing: border-box;
7146
+ aspect-ratio: 1 / 1;
7147
+ height: 100%;
7148
+ padding: 0.2em;
7149
+ align-items: center;
7150
+ justify-content: center;
7049
7151
  color: currentColor;
7050
7152
  font-size: inherit;
7051
7153
  background: none;
@@ -7127,7 +7229,7 @@ const openCallout = (message, {
7127
7229
  skipFocus = false,
7128
7230
  debug = () => {}
7129
7231
  } = {}) => {
7130
- import.meta.css = [css$13, "@jsenv/navi/src/control/rules/callout/callout.js"];
7232
+ import.meta.css = [css$12, "@jsenv/navi/src/control/rules/callout/callout.js"];
7131
7233
  if (debug === true) {
7132
7234
  debug = (e, ...args) => console.debug(`"${e.type}" -> `, ...args);
7133
7235
  }
@@ -7309,11 +7411,11 @@ const openCallout = (message, {
7309
7411
  } else if (newMessage instanceof Node) {
7310
7412
  // Handle DOM node (cloned from CSS selector)
7311
7413
  debug(`callout update message (node)`);
7312
- calloutMessageElement.innerHTML = "";
7414
+ clearCalloutMessage(calloutMessageElement);
7313
7415
  calloutMessageElement.appendChild(newMessage);
7314
7416
  } else if (typeof newMessage === "function") {
7315
7417
  debug(`callout update message (function)`);
7316
- calloutMessageElement.innerHTML = "";
7418
+ clearCalloutMessage(calloutMessageElement);
7317
7419
  newMessage({
7318
7420
  renderIntoCallout: jsx => renderIntoCallout(jsx, calloutMessageElement, {
7319
7421
  requestClose
@@ -7338,12 +7440,13 @@ const openCallout = (message, {
7338
7440
  iframe.style.backgroundColor = "white";
7339
7441
  iframe.srcdoc = newMessage;
7340
7442
  debug(`callout update message (html document iframe)`);
7341
- // Clear existing content and add iframe
7342
- calloutMessageElement.innerHTML = "";
7443
+ clearCalloutMessage(calloutMessageElement);
7343
7444
  calloutMessageElement.appendChild(iframe);
7344
7445
  } else {
7345
7446
  debug(`callout update message: ${typeof newMessage === "string" ? newMessage.slice(0, 80) : String(newMessage)}`);
7346
- calloutMessageElement.innerHTML = newMessage;
7447
+ renderHtmlIntoCallout(String(newMessage), calloutMessageElement, {
7448
+ requestClose
7449
+ });
7347
7450
  }
7348
7451
  }
7349
7452
  // After updating content the callout size likely changed — re-position immediately
@@ -16627,16 +16730,10 @@ const getHowToHandleStyleProp = (name) => {
16627
16730
  }
16628
16731
  return getStyle;
16629
16732
  };
16630
- const prepareStyleValue = (
16631
- existingValue,
16632
- value,
16633
- name,
16634
- styleContext,
16635
- context,
16636
- ) => {
16733
+ const prepareStyleValue = (existingValue, value, name, styleContext) => {
16637
16734
  const stringifier = getStringifier(name);
16638
- const cssValue = stringifier(value, name, styleContext, context);
16639
- const mergedValue = mergeOneStyle(existingValue, cssValue, name, context);
16735
+ const cssValue = stringifier(value, name, styleContext);
16736
+ const mergedValue = mergeOneStyle(existingValue, cssValue, name, "css");
16640
16737
  return mergedValue;
16641
16738
  };
16642
16739
 
@@ -17725,30 +17822,61 @@ const isKeyboardModality = () => keyboardNavigationUsed;
17725
17822
  return false;
17726
17823
  };
17727
17824
 
17728
- // Shared setup for :focus and :focus-visible. Both need focusin/focusout
17729
- // listeners + a MutationObserver on aria-controls so that when the attribute
17730
- // changes while the element is focused, old and new controlled elements are
17731
- // notified to re-check their own focus state.
17732
- // extraSetup: optional (el, callback) => teardown for pseudo-class-specific
17733
- // listeners (e.g. keydown/keyup for :focus-visible).
17734
- const setupFocus = (el, callback) => {
17735
- const onFocusChange = (e) => {
17736
- callback();
17737
- notifyAriaControlled(el, e);
17738
- };
17739
- el.addEventListener("focusin", onFocusChange);
17740
- el.addEventListener("focusout", onFocusChange);
17741
- // Only observe aria-controls mutations when the element already has the
17742
- // attribute at setup time. If aria-controls is guaranteed to be set before
17743
- // initPseudoStyles runs (e.g. passed as a prop in box.jsx), this covers all
17744
- // real cases without paying the MutationObserver cost for every element.
17745
- let observer;
17746
- // if (el.hasAttribute("aria-controls")) {
17747
- observer = new MutationObserver((mutations) => {
17748
- if (!el.matches(":focus-within")) {
17749
- return;
17825
+ // One registration per element for everything focus-related, shared by
17826
+ // :focus, :focus-visible and :focus-within: they all react to the same
17827
+ // focusin/focusout, and they all re-check through the same callback (see
17828
+ // initPseudoStyles), which the Set turns into one call.
17829
+ const focusTrackingWeakMap = new WeakMap();
17830
+ const trackFocus = (el, callback) => {
17831
+ let tracking = focusTrackingWeakMap.get(el);
17832
+ if (!tracking) {
17833
+ const callbackSet = new Set();
17834
+ const onFocusChange = (e) => {
17835
+ for (const trackedCallback of callbackSet) {
17836
+ trackedCallback();
17837
+ }
17838
+ notifyAriaControlled(el, e);
17839
+ };
17840
+ el.addEventListener("focusin", onFocusChange);
17841
+ el.addEventListener("focusout", onFocusChange);
17842
+ tracking = {
17843
+ callbackSet,
17844
+ teardown: () => {
17845
+ el.removeEventListener("focusin", onFocusChange);
17846
+ el.removeEventListener("focusout", onFocusChange);
17847
+ focusTrackingWeakMap.delete(el);
17848
+ },
17849
+ };
17850
+ focusTrackingWeakMap.set(el, tracking);
17851
+ observeAriaControls();
17852
+ }
17853
+ tracking.callbackSet.add(callback);
17854
+ return () => {
17855
+ tracking.callbackSet.delete(callback);
17856
+ if (tracking.callbackSet.size === 0) {
17857
+ tracking.teardown();
17750
17858
  }
17859
+ };
17860
+ };
17861
+ // When aria-controls changes on a focused element, what it used to control
17862
+ // and what it controls now both re-check their inherited focus. One observer
17863
+ // on the document rather than one per tracked element: the attribute changes
17864
+ // rarely, on few elements, while thousands are tracked — and it can be set
17865
+ // after the element was, so "has it at setup" would miss it.
17866
+ let ariaControlsObserver = null;
17867
+ const observeAriaControls = () => {
17868
+ if (ariaControlsObserver) {
17869
+ return;
17870
+ }
17871
+ ariaControlsObserver = new MutationObserver((mutations) => {
17751
17872
  for (const mutation of mutations) {
17873
+ const el = mutation.target;
17874
+ if (!focusTrackingWeakMap.has(el)) {
17875
+ continue;
17876
+ }
17877
+ if (!el.matches(":focus-within")) {
17878
+ continue;
17879
+ }
17752
17880
  const oldIds = (mutation.oldValue || "").split(" ").filter(Boolean);
17753
17881
  for (const id of oldIds) {
17754
17882
  const controlled = document.getElementById(id);
@@ -17756,30 +17884,20 @@ const isKeyboardModality = () => keyboardNavigationUsed;
17756
17884
  requestPseudoStateCheck(controlled, {});
17757
17885
  }
17758
17886
  }
17887
+ notifyAriaControlled(el, {});
17759
17888
  }
17760
- notifyAriaControlled(el, {});
17761
17889
  });
17762
- observer.observe(el, {
17890
+ ariaControlsObserver.observe(document.documentElement, {
17891
+ subtree: true,
17763
17892
  attributes: true,
17764
17893
  attributeFilter: ["aria-controls"],
17765
17894
  attributeOldValue: true,
17766
17895
  });
17767
- // }
17768
- return () => {
17769
- el.removeEventListener("focusin", onFocusChange);
17770
- el.removeEventListener("focusout", onFocusChange);
17771
- observer?.disconnect();
17772
- };
17773
17896
  };
17774
17897
 
17775
17898
  definePseudoClass(":focus", {
17776
17899
  attribute: "data-focus",
17777
- setup: (el, callback) => {
17778
- const cleanup = setupFocus(el, callback);
17779
- return () => {
17780
- cleanup();
17781
- };
17782
- },
17900
+ setup: trackFocus,
17783
17901
  test: (el) => {
17784
17902
  if (el.matches(":focus")) {
17785
17903
  return true;
@@ -17795,9 +17913,7 @@ const isKeyboardModality = () => keyboardNavigationUsed;
17795
17913
  // No per-element keydown/keyup listener: the shared recheckFocusChainOnKey
17796
17914
  // handler re-checks the focused element (the only one a keystroke can turn
17797
17915
  // focus-visible) so a keypress stays O(1), not O(number-of-boxes).
17798
- setup: (el, callback) => {
17799
- return setupFocus(el, callback);
17800
- },
17916
+ setup: trackFocus,
17801
17917
  test: (el) => {
17802
17918
  if (isMatchingFocusVisible(el)) {
17803
17919
  return true;
@@ -17810,18 +17926,7 @@ const isKeyboardModality = () => keyboardNavigationUsed;
17810
17926
  });
17811
17927
  definePseudoClass(":focus-within", {
17812
17928
  attribute: "data-focus-within",
17813
- setup: (el, callback) => {
17814
- const onFocusChange = (e) => {
17815
- callback();
17816
- notifyAriaControlled(el, e);
17817
- };
17818
- el.addEventListener("focusin", onFocusChange);
17819
- el.addEventListener("focusout", onFocusChange);
17820
- return () => {
17821
- el.removeEventListener("focusin", onFocusChange);
17822
- el.removeEventListener("focusout", onFocusChange);
17823
- };
17824
- },
17929
+ setup: trackFocus,
17825
17930
  test: (el) => {
17826
17931
  if (el.matches(":focus-within")) {
17827
17932
  return true;
@@ -18116,8 +18221,18 @@ const initPseudoStyles = (
18116
18221
  onStateChange(state, oldPseudoState);
18117
18222
  }),
18118
18223
  );
18119
- element.addEventListener("navi_pseudo_state_request_check", () => {
18224
+ // One function for every way a re-check can be asked, so that setups
18225
+ // registering it side by side (the focus tracking, for one) hold the same
18226
+ // callback and call it once.
18227
+ const requestCheck = () => {
18120
18228
  checkPseudoClasses();
18229
+ };
18230
+ element.addEventListener("navi_pseudo_state_request_check", requestCheck);
18231
+ addTeardown(() => {
18232
+ element.removeEventListener(
18233
+ "navi_pseudo_state_request_check",
18234
+ requestCheck,
18235
+ );
18121
18236
  });
18122
18237
 
18123
18238
  for (const pseudoClass of pseudoClasses) {
@@ -18128,9 +18243,7 @@ const initPseudoStyles = (
18128
18243
  }
18129
18244
  const { setup } = pseudoClassDefinition;
18130
18245
  if (setup) {
18131
- const cleanup = setup(element, () => {
18132
- checkPseudoClasses();
18133
- });
18246
+ const cleanup = setup(element, requestCheck);
18134
18247
  addTeardown(cleanup);
18135
18248
  }
18136
18249
  }
@@ -18150,6 +18263,11 @@ const applyStyle = (
18150
18263
  return;
18151
18264
  }
18152
18265
  const styleToApply = getStyleToApply(style, pseudoState, pseudoNamedStyles);
18266
+ // The same object comes back for every state change of a box whose inline
18267
+ // style has no pseudo entry: nothing to write.
18268
+ if (appliedStyleWeakMap.get(element) === styleToApply) {
18269
+ return;
18270
+ }
18153
18271
  updateStyle(element, styleToApply, preventInitialTransition);
18154
18272
  };
18155
18273
 
@@ -18164,52 +18282,74 @@ const getStyleToApply = (styles, pseudoState, pseudoNamedStyles) => {
18164
18282
  ) {
18165
18283
  return styles;
18166
18284
  }
18167
-
18168
- const isMatching = (pseudoKey) => {
18169
- if (pseudoKey.startsWith("::")) {
18170
- const nextColonIndex = pseudoKey.indexOf(":", 2);
18171
- if (nextColonIndex === -1) {
18172
- return true;
18173
- }
18174
- // Handle pseudo-elements with states like "::-navi-loader:checked:disabled"
18175
- const pseudoStatesString = pseudoKey.slice(nextColonIndex);
18176
- return isMatching(pseudoStatesString);
18285
+ let style = styles;
18286
+ for (const pseudoKey of Object.keys(pseudoNamedStyles)) {
18287
+ const requiredStates = getPseudoKeyRequiredStates(pseudoKey);
18288
+ if (!requiredStates.every((state) => pseudoState[state])) {
18289
+ continue;
18177
18290
  }
18178
- const nextColonIndex = pseudoKey.indexOf(":", 1);
18179
- if (nextColonIndex === -1) {
18180
- return pseudoState[pseudoKey];
18291
+ if (style === styles) {
18292
+ style = { ...styles };
18181
18293
  }
18182
- // Handle compound pseudo-states like ":checked:disabled"
18183
- return pseudoKey
18184
- .slice(1)
18185
- .split(":")
18186
- .every((state) => pseudoState[state]);
18187
- };
18188
-
18189
- const styleToAddSet = new Set();
18190
- for (const pseudoKey of Object.keys(pseudoNamedStyles)) {
18191
- if (isMatching(pseudoKey)) {
18192
- const stylesToApply = pseudoNamedStyles[pseudoKey];
18193
- styleToAddSet.add(stylesToApply);
18294
+ // Both sides are already normalized for CSS by the box; only the
18295
+ // properties that compose (a press scale on top of a translate) go through
18296
+ // a merge, the rest is a plain override.
18297
+ const styleToAdd = pseudoNamedStyles[pseudoKey];
18298
+ for (const key of Object.keys(styleToAdd)) {
18299
+ const value = styleToAdd[key];
18300
+ if (value === undefined) {
18301
+ continue;
18302
+ }
18303
+ if (key === "transform" || key === "willChange") {
18304
+ style[key] = mergeOneStyle(style[key], value, key, "css");
18305
+ } else {
18306
+ style[key] = value;
18307
+ }
18194
18308
  }
18195
18309
  }
18196
- if (styleToAddSet.size === 0) {
18197
- return styles;
18310
+ return style;
18311
+ };
18312
+
18313
+ // The state names a pseudo key asks for, parsed once: the same few keys come
18314
+ // back on every state change of every box that has them. "::x" alone always
18315
+ // matches; "::x:a:b" and ":a:b" ask for ":a" and ":b" — the state keys as
18316
+ // checkPseudoClasses writes them, colon included.
18317
+ const pseudoKeyRequiredStatesMap = new Map();
18318
+ const getPseudoKeyRequiredStates = (pseudoKey) => {
18319
+ const cached = pseudoKeyRequiredStatesMap.get(pseudoKey);
18320
+ if (cached) {
18321
+ return cached;
18198
18322
  }
18199
- let style = styles || {};
18200
- for (const styleToAdd of styleToAddSet) {
18201
- style = mergeTwoStyles(style, styleToAdd, "css");
18323
+ let requiredStates;
18324
+ if (pseudoKey.startsWith("::")) {
18325
+ const nextColonIndex = pseudoKey.indexOf(":", 2);
18326
+ requiredStates =
18327
+ nextColonIndex === -1
18328
+ ? []
18329
+ : getPseudoKeyRequiredStates(pseudoKey.slice(nextColonIndex));
18330
+ } else {
18331
+ const nextColonIndex = pseudoKey.indexOf(":", 1);
18332
+ requiredStates =
18333
+ nextColonIndex === -1
18334
+ ? [pseudoKey]
18335
+ : pseudoKey
18336
+ .slice(1)
18337
+ .split(":")
18338
+ .map((state) => `:${state}`);
18202
18339
  }
18203
- return style;
18340
+ pseudoKeyRequiredStatesMap.set(pseudoKey, requiredStates);
18341
+ return requiredStates;
18204
18342
  };
18205
18343
 
18206
- const styleKeySetWeakMap = new WeakMap();
18344
+ // element the style object last written to it, so the next one is written
18345
+ // as a difference: the values that changed, the keys it no longer has.
18346
+ const appliedStyleWeakMap = new WeakMap();
18207
18347
  const elementTransitionWeakMap = new WeakMap();
18208
18348
  const elementRenderedWeakSet = new WeakSet();
18209
- const NO_STYLE_KEY_SET = new Set();
18349
+ const NO_STYLE = {};
18210
18350
  const updateStyle = (element, style, preventInitialTransition) => {
18211
- const styleKeySet = style ? new Set(Object.keys(style)) : NO_STYLE_KEY_SET;
18212
- const oldStyleKeySet = styleKeySetWeakMap.get(element) || NO_STYLE_KEY_SET;
18351
+ const styleToApply = style || NO_STYLE;
18352
+ const styleApplied = appliedStyleWeakMap.get(element) || NO_STYLE;
18213
18353
  // TRANSITION ANTI-FLICKER STRATEGY:
18214
18354
  // Problem: When setting both transition and styled properties simultaneously
18215
18355
  // (e.g., el.style.transition = "border-radius 0.3s ease"; el.style.borderRadius = "20px"),
@@ -18219,32 +18359,32 @@ const updateStyle = (element, style, preventInitialTransition) => {
18219
18359
  // transition to "none", then restore the intended transition after the frame completes.
18220
18360
  // We handle multiple updateStyle calls in the same frame gracefully - only one
18221
18361
  // requestAnimationFrame is scheduled per element, and the final transition value wins.
18222
- let styleKeySetToApply = styleKeySet;
18362
+ let skipTransition = false;
18223
18363
  if (!elementRenderedWeakSet.has(element)) {
18224
- const hasTransition = styleKeySet.has("transition");
18364
+ const hasTransition = Object.hasOwn(styleToApply, "transition");
18225
18365
  if (hasTransition || preventInitialTransition) {
18226
- if (elementTransitionWeakMap.has(element)) {
18227
- elementTransitionWeakMap.set(element, style?.transition);
18228
- } else {
18366
+ if (!elementTransitionWeakMap.has(element)) {
18229
18367
  element.style.transition = "none";
18230
- elementTransitionWeakMap.set(element, style?.transition);
18231
18368
  }
18232
- // Don't apply the transition property now - we've set it to "none" temporarily
18233
- styleKeySetToApply = new Set(styleKeySet);
18234
- styleKeySetToApply.delete("transition");
18369
+ elementTransitionWeakMap.set(element, styleToApply.transition);
18370
+ // Stays "none" until the first frame puts the intended value back
18371
+ skipTransition = true;
18235
18372
  }
18236
18373
  afterFirstFrame(element);
18237
18374
  }
18238
18375
 
18239
- // Apply all styles normally (excluding transition during anti-flicker)
18240
- const keysToDelete = new Set(oldStyleKeySet);
18241
- for (const key of styleKeySetToApply) {
18242
- const value = style[key];
18376
+ for (const key of Object.keys(styleToApply)) {
18377
+ const value = styleToApply[key];
18243
18378
  if (value === undefined || value === null) {
18244
- // Treat undefined/null as "remove" leave key in keysToDelete
18379
+ // a removal: handled below with the keys this style no longer has
18380
+ continue;
18381
+ }
18382
+ if (skipTransition && key === "transition") {
18383
+ continue;
18384
+ }
18385
+ if (styleApplied[key] === value) {
18245
18386
  continue;
18246
18387
  }
18247
- keysToDelete.delete(key);
18248
18388
  if (key.startsWith("--")) {
18249
18389
  element.style.setProperty(key, value);
18250
18390
  } else {
@@ -18252,8 +18392,15 @@ const updateStyle = (element, style, preventInitialTransition) => {
18252
18392
  }
18253
18393
  }
18254
18394
 
18255
- // Remove obsolete styles
18256
- for (const key of keysToDelete) {
18395
+ for (const key of Object.keys(styleApplied)) {
18396
+ const previousValue = styleApplied[key];
18397
+ if (previousValue === undefined || previousValue === null) {
18398
+ continue;
18399
+ }
18400
+ const value = styleToApply[key];
18401
+ if (value !== undefined && value !== null) {
18402
+ continue;
18403
+ }
18257
18404
  if (key.startsWith("--")) {
18258
18405
  element.style.removeProperty(key);
18259
18406
  } else {
@@ -18261,7 +18408,7 @@ const updateStyle = (element, style, preventInitialTransition) => {
18261
18408
  }
18262
18409
  }
18263
18410
 
18264
- styleKeySetWeakMap.set(element, styleKeySet);
18411
+ appliedStyleWeakMap.set(element, styleToApply);
18265
18412
  };
18266
18413
 
18267
18414
  // One frame for every element waiting for its first one, not one frame each.
@@ -18313,8 +18460,10 @@ const useComposeElementRef = (syncElement, externalRef) => {
18313
18460
  const cleanupRef = useRef(null);
18314
18461
  const elRef = useRef(null);
18315
18462
  const prevSyncElementRef = useRef(undefined);
18316
- const refCallbackRef = useRef(null);
18463
+ const stableRef = useRef(null);
18317
18464
  const externalRefRef = useRef(externalRef);
18465
+ const syncElementRef = useRef(syncElement);
18466
+ syncElementRef.current = syncElement;
18318
18467
  // Detect external ref identity change between renders. The refCallback is
18319
18468
  // stable across renders, so when the parent passes a new ref object (or
18320
18469
  // switches from null to a ref), Preact does NOT re-fire the callback while
@@ -18338,24 +18487,24 @@ const useComposeElementRef = (syncElement, externalRef) => {
18338
18487
  }
18339
18488
  externalRefRef.current = externalRef;
18340
18489
 
18341
- const runSync = (el) => {
18342
- if (cleanupRef.current) {
18343
- cleanupRef.current();
18344
- cleanupRef.current = null;
18345
- }
18346
- prevSyncElementRef.current = syncElement;
18347
- const cleanup = syncElement(el);
18348
- if (typeof cleanup === "function") {
18349
- cleanupRef.current = cleanup;
18350
- }
18351
- };
18352
-
18353
- // If element already mounted, re-sync when syncElement reference changed.
18354
- if (elRef.current && syncElement !== prevSyncElementRef.current) {
18355
- runSync(elRef.current);
18356
- }
18357
-
18358
- if (!refCallbackRef.current) {
18490
+ if (!stableRef.current) {
18491
+ // Created once, like the ref callback that calls it, and reading the sync
18492
+ // function through a ref for that reason: the element can be replaced long
18493
+ // after the first render — a tag that changes, a box hidden then shown —
18494
+ // and what the new element gets must be the current render's sync, not
18495
+ // the one the first render closed over.
18496
+ const runSync = (el) => {
18497
+ if (cleanupRef.current) {
18498
+ cleanupRef.current();
18499
+ cleanupRef.current = null;
18500
+ }
18501
+ const syncElementNow = syncElementRef.current;
18502
+ prevSyncElementRef.current = syncElementNow;
18503
+ const cleanup = syncElementNow(el);
18504
+ if (typeof cleanup === "function") {
18505
+ cleanupRef.current = cleanup;
18506
+ }
18507
+ };
18359
18508
  const refCallback = (el) => {
18360
18509
  elRef.current = el;
18361
18510
  // Keep .current in sync immediately so useEffect callbacks that read
@@ -18379,10 +18528,15 @@ const useComposeElementRef = (syncElement, externalRef) => {
18379
18528
  prevSyncElementRef.current = undefined;
18380
18529
  }
18381
18530
  };
18382
- refCallbackRef.current = refCallback;
18531
+ stableRef.current = { refCallback, runSync };
18532
+ }
18533
+ const { refCallback, runSync } = stableRef.current;
18534
+
18535
+ // If element already mounted, re-sync when syncElement reference changed.
18536
+ if (elRef.current && syncElement !== prevSyncElementRef.current) {
18537
+ runSync(elRef.current);
18383
18538
  }
18384
18539
 
18385
- const refCallback = refCallbackRef.current;
18386
18540
  refCallback.current = elRef.current;
18387
18541
  return refCallback;
18388
18542
  };
@@ -18576,12 +18730,6 @@ import.meta.css = [/* css */`
18576
18730
  told it may, and without that the body grows instead of scrolling */
18577
18731
  min-height: 0;
18578
18732
  flex: 0 1 auto;
18579
- /* Overflow makes it focusable via tab: apply the outline styles */
18580
- outline-width: var(--navi-focus-outline-width);
18581
- /* Outline must appear ON the body, not outside */
18582
- /* Because for instance when body is within dialog or slide with overflow: hidden it would not be visible */
18583
- outline-offset: calc(-1 * var(--navi-focus-outline-width));
18584
- overflow: auto;
18585
18733
 
18586
18734
  /* The same reading as the header's corners above, on all four: a body
18587
18735
  follows the corners of the box it is drawn in — which is also what
@@ -18590,6 +18738,12 @@ import.meta.css = [/* css */`
18590
18738
  border-top-right-radius: inherit;
18591
18739
  border-bottom-right-radius: inherit;
18592
18740
  border-bottom-left-radius: inherit;
18741
+ /* Overflow makes it focusable via tab: apply the outline styles */
18742
+ outline-width: var(--navi-focus-outline-width);
18743
+ /* Outline must appear ON the body, not outside */
18744
+ /* Because for instance when body is within dialog or slide with overflow: hidden it would not be visible */
18745
+ outline-offset: calc(-1 * var(--navi-focus-outline-width));
18746
+ overflow: auto;
18593
18747
 
18594
18748
  &:focus-visible {
18595
18749
  outline-style: solid;
@@ -18736,6 +18890,7 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
18736
18890
  * childPropSet?: Set<string>,
18737
18891
  * preventInitialTransition?: boolean,
18738
18892
  * separator?: import("ignore:preact").ComponentChildren | ((index: number) => import("ignore:preact").ComponentChildren),
18893
+ * ownTarget?: boolean | "refuse" | "always",
18739
18894
  * children?: import("ignore:preact").ComponentChildren,
18740
18895
  * [key: string]: any,
18741
18896
  * }>}
@@ -18743,10 +18898,94 @@ const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]);
18743
18898
  const Box = props => {
18744
18899
  const {
18745
18900
  ref,
18901
+ children,
18902
+ separator,
18903
+ interactions,
18904
+ ...computeProps
18905
+ } = props;
18906
+ const parentBoxFlow = useContext(BoxFlowContext);
18907
+ // Which interactions this box answers, and with what. Read here rather than
18908
+ // on the control, so a swipe or a hold can be declared on anything — a row, a
18909
+ // card, a block of text — and reach the control it belongs to (which is what
18910
+ // carries the action, and what knows it is disabled) by looking for it.
18911
+ // Read through a ref by the effect below: what an interaction DOES is this
18912
+ // render, while WHEN it happens is wired once, at mount (see
18913
+ // useInteractionsEffect).
18914
+ const interactionsRef = useRef(null);
18915
+ interactionsRef.current = resolveInteractions(interactions);
18916
+
18917
+ // What the props say about this box is worked out once per distinct set of
18918
+ // them: a parent re-rendering hands every box below it a new props object,
18919
+ // and most of the time nothing in it has changed. Handlers are the
18920
+ // exception — a closure is new on every render — so they are compared by
18921
+ // name only and put back fresh, see withCurrentHandlers.
18922
+ const renderMemoRef = useRef(null);
18923
+ const renderMemo = renderMemoRef.current;
18924
+ let computed;
18925
+ if (renderMemo && renderMemo.parentBoxFlow === parentBoxFlow && arePropsEquivalent(renderMemo.props, computeProps)) {
18926
+ computed = withCurrentHandlers(renderMemo.computed, computeProps);
18927
+ } else {
18928
+ computed = computeBox(computeProps, parentBoxFlow);
18929
+ }
18930
+ renderMemoRef.current = {
18931
+ props: computeProps,
18932
+ parentBoxFlow,
18933
+ computed
18934
+ };
18935
+ const {
18936
+ TagName,
18937
+ boxFlow,
18938
+ boxFlowIsDefault,
18939
+ row,
18940
+ column,
18941
+ aspectRatio,
18942
+ visualSelector,
18943
+ innerClassName,
18944
+ selfForwardedProps,
18945
+ childForwardedProps,
18946
+ styleDeps
18947
+ } = computed;
18948
+ const syncBox = useCallback(computed.syncBox, styleDeps);
18949
+ const finalRef = useComposeElementRef(syncBox, ref);
18950
+ useInteractionsEffect(finalRef, interactionsRef);
18951
+ let innerChildren = children;
18952
+ if (separator) {
18953
+ // Flatten nested arrays (e.g., from .map()) to treat each element as individual child
18954
+ innerChildren = applySeparatorOnChildren(innerChildren, separator);
18955
+ }
18956
+
18957
+ // When hasChildUsingForwardedProps is used it means
18958
+ // Some/all the children needs to access remainingProps
18959
+ // to render and will provide a function to do so.
18960
+ if (props.hasChildUsingForwardedProps) {
18961
+ innerChildren = jsx(BoxForwardedPropsContext.Provider, {
18962
+ value: childForwardedProps,
18963
+ children: innerChildren
18964
+ });
18965
+ }
18966
+ return jsx(TagName, {
18967
+ ref: finalRef,
18968
+ className: innerClassName,
18969
+ "navi-box-flow": boxFlowIsDefault ? undefined : boxFlow,
18970
+ "navi-box-flow-row": row ? "" : undefined,
18971
+ "navi-box-flow-column": column ? "" : undefined,
18972
+ "navi-aspect-ratio": aspectRatio ? aspectRatio : undefined,
18973
+ "data-visual-selector": visualSelector,
18974
+ ...selfForwardedProps,
18975
+ children: jsx(BoxFlowContext.Provider, {
18976
+ value: boxFlow,
18977
+ children: innerChildren
18978
+ })
18979
+ });
18980
+ };
18981
+
18982
+ // Everything the props decide, for the JSX and for the DOM sync. Pure, which
18983
+ // is what lets Box keep the previous result when the props are equivalent.
18984
+ const computeBox = (props, parentBoxFlow) => {
18985
+ const {
18746
18986
  as: asProp = "div",
18747
18987
  baseClassName,
18748
18988
  className,
18749
- baseStyle,
18750
18989
  // style management
18751
18990
  style,
18752
18991
  styleCSSVars = STYLE_CSS_VARS_DEFAULT,
@@ -18772,8 +19011,6 @@ const Box = props => {
18772
19011
  // (when transition is set via props, this is done automatically)
18773
19012
  // so this prop is useful only when transition is enabled from "outside" (via CSS)
18774
19013
  preventInitialTransition,
18775
- children,
18776
- separator,
18777
19014
  // Layout roles inside a scrolling container (a Dialog, a Popover): the
18778
19015
  // header stays at the top and the footer at the bottom while the rest
18779
19016
  // scrolls, or — when a body is present — the body is what scrolls and the
@@ -18783,22 +19020,24 @@ const Box = props => {
18783
19020
  header,
18784
19021
  footer,
18785
19022
  body,
18786
- // Which interactions this box answers, and with what. Read here rather than
18787
- // on the control, so a swipe or a hold can be declared on anything — a row, a
18788
- // card, a block of text and reach the control it belongs to (which is what
18789
- // carries the action, and what knows it is disabled) by looking for it.
18790
- interactions,
19023
+ // A press landing here is aimed AT this box, not at whatever it sits in — a
19024
+ // cross an application draws in a card's corner, a badge on a row that
19025
+ // travels. Writing the attribute is the whole of it here: it is read off
19026
+ // the DOM by the controls above and by the gesture readers, and what an
19027
+ // affordance makes of the read-only around it is a question only a control
19028
+ // can answer (see own_target.js).
19029
+ ownTarget,
18791
19030
  ...rest
18792
19031
  } = props;
19032
+ if (ownTarget) {
19033
+ rest[OWN_TARGET_ATTRIBUTE] = typeof ownTarget === "string" ? ownTarget : "";
19034
+ }
18793
19035
  let as = asProp;
18794
19036
 
18795
19037
  // A box that scrolls is what gives header/footer/body their meaning, and
18796
19038
  // saying overflow="auto" is already saying it — no second prop for the same
18797
19039
  // fact. Dialog and Popover get it the same way, by asking for that overflow.
18798
- const scrolls = ["overflow", "overflowX", "overflowY"].some(name => {
18799
- const value = rest[name];
18800
- return value === "auto" || value === "scroll";
18801
- });
19040
+ const scrolls = isScrollingOverflow(rest.overflow) || isScrollingOverflow(rest.overflowX) || isScrollingOverflow(rest.overflowY);
18802
19041
  // <header>/<footer> rather than a div: the role is exactly what those tags
18803
19042
  // mean, and a screen reader gets it for free. The body stays a div — <main>
18804
19043
  // means "the main content of the document", which a popup's body is not.
@@ -18834,8 +19073,6 @@ const Box = props => {
18834
19073
  }
18835
19074
  }
18836
19075
  const defaultDisplay = getDefaultDisplay(TagName);
18837
- // Read the parent flow early so we can use it when display="inherit" is requested.
18838
- const parentBoxFlow = useContext(BoxFlowContext);
18839
19076
  let {
18840
19077
  inline,
18841
19078
  block,
@@ -18911,12 +19148,6 @@ const Box = props => {
18911
19148
  }
18912
19149
  const boxFlowIsDefault = boxFlow === defaultDisplay;
18913
19150
 
18914
- // Read through a ref by the effect below: what an interaction DOES is this
18915
- // render, while WHEN it happens is wired once, at mount (see
18916
- // useInteractionsEffect).
18917
- const interactionsRef = useRef(null);
18918
- interactionsRef.current = resolveInteractions(interactions);
18919
- const remainingPropKeySet = new Set(Object.keys(rest));
18920
19151
  // The box is only a frame and one of its descendants IS the component (a Button
18921
19152
  // and its content): everything, event handlers included, belongs to that
18922
19153
  // descendant.
@@ -18924,15 +19155,20 @@ const Box = props => {
18924
19155
  const innerClassName = withPropsClassName(baseClassName, className);
18925
19156
  const selfForwardedProps = {};
18926
19157
  const childForwardedProps = {};
18927
- let finalRef;
19158
+ let styleDeps;
19159
+ let syncBox;
18928
19160
  {
18929
- const styleDeps = [
19161
+ styleDeps = [
18930
19162
  // Layout and alignment props
18931
19163
  parentBoxFlow, boxFlow,
18932
19164
  // Style context dependencies
18933
19165
  styleCSSVars, pseudoClasses, pseudoElements,
18934
19166
  // Selectors
18935
19167
  visualSelector, pseudoStateSelector, preventInitialTransition];
19168
+ // The pseudo state goes into the deps as key/value entries and never as the
19169
+ // object itself: a control builds that object on every render, and its
19170
+ // identity in the deps would re-run the sync — listeners, observers, style
19171
+ // writes — on each of them, for a state that has not changed.
18936
19172
  let innerPseudoState;
18937
19173
  if (basePseudoState && pseudoState) {
18938
19174
  innerPseudoState = {};
@@ -18942,30 +19178,28 @@ const Box = props => {
18942
19178
  if (pseudoStateKeySet.has(key)) {
18943
19179
  pseudoStateKeySet.delete(key);
18944
19180
  const value = pseudoState[key];
18945
- styleDeps.push(value);
19181
+ styleDeps.push(key, value);
18946
19182
  innerPseudoState[key] = value;
18947
19183
  } else {
18948
19184
  const value = basePseudoState[key];
18949
- styleDeps.push(value);
19185
+ styleDeps.push(key, value);
18950
19186
  innerPseudoState[key] = value;
18951
19187
  }
18952
19188
  }
18953
19189
  for (const key of pseudoStateKeySet) {
18954
19190
  const value = pseudoState[key];
18955
- styleDeps.push(value);
19191
+ styleDeps.push(key, value);
18956
19192
  innerPseudoState[key] = value;
18957
19193
  }
18958
19194
  } else if (basePseudoState) {
18959
19195
  innerPseudoState = basePseudoState;
18960
19196
  for (const key of Object.keys(basePseudoState)) {
18961
- const value = basePseudoState[key];
18962
- styleDeps.push(value);
19197
+ styleDeps.push(key, basePseudoState[key]);
18963
19198
  }
18964
19199
  } else if (pseudoState) {
18965
19200
  innerPseudoState = pseudoState;
18966
19201
  for (const key of Object.keys(pseudoState)) {
18967
- const value = pseudoState[key];
18968
- styleDeps.push(value);
19202
+ styleDeps.push(key, pseudoState[key]);
18969
19203
  }
18970
19204
  } else {
18971
19205
  innerPseudoState = PSEUDO_STATE_DEFAULT;
@@ -18983,8 +19217,8 @@ const Box = props => {
18983
19217
  };
18984
19218
  let boxPseudoNamedStyles = PSEUDO_NAMED_STYLES_DEFAULT;
18985
19219
  const canForwardToChild = hasChildUsingForwardedProps;
18986
- const addStyle = (value, name, styleContext, stylesTarget, context) => {
18987
- const mergedValue = prepareStyleValue(stylesTarget[name], value, name, styleContext, context);
19220
+ const addStyle = (value, name, styleContext, stylesTarget) => {
19221
+ const mergedValue = prepareStyleValue(stylesTarget[name], value, name, styleContext);
18988
19222
  const cssVar = styleContext.styleCSSVars[name];
18989
19223
  if (cssVar) {
18990
19224
  addCSSVar(mergedValue, cssVar, stylesTarget);
@@ -19006,20 +19240,20 @@ const Box = props => {
19006
19240
  styleDeps.push(name, value); // impact box style -> add to deps
19007
19241
  stylesTarget[name] = value;
19008
19242
  };
19009
- const addStyleMaybeForwarding = (value, name, styleContext, stylesTarget, context, visualChildPropStrategy) => {
19243
+ const addStyleMaybeForwarding = (value, name, styleContext, stylesTarget, visualChildPropStrategy) => {
19010
19244
  if (!visualChildPropStrategy) {
19011
- addStyle(value, name, styleContext, stylesTarget, context);
19245
+ addStyle(value, name, styleContext, stylesTarget);
19012
19246
  return false;
19013
19247
  }
19014
19248
  const cssVar = styleCSSVars[name];
19015
19249
  if (cssVar) {
19016
19250
  // css var wins over visual child handling
19017
- addStyle(value, name, styleContext, stylesTarget, context);
19251
+ addStyle(value, name, styleContext, stylesTarget);
19018
19252
  return false;
19019
19253
  }
19020
19254
  if (visualChildPropStrategy === "copy") {
19021
19255
  // we stylyze ourself + forward prop to the child
19022
- addStyle(value, name, styleContext, stylesTarget, context);
19256
+ addStyle(value, name, styleContext, stylesTarget);
19023
19257
  }
19024
19258
  if (!canForwardToChild) {
19025
19259
  return false;
@@ -19032,13 +19266,15 @@ const Box = props => {
19032
19266
  // style={{ ":hover": { backgroundColor: "red" } }}
19033
19267
  // then we'll track ":hover" state changes even for basic elements like <div>
19034
19268
  const pseudoClassesFromStyleSet = new Set();
19035
- boxPseudoNamedStyles = {};
19036
19269
  const visitProp = (value, name, styleContext, boxStylesTarget, styleOrigin) => {
19037
19270
  const isPseudoElement = name.startsWith("::");
19038
19271
  const isPseudoClass = name.startsWith(":");
19039
19272
  if (isPseudoElement || isPseudoClass) {
19040
19273
  styleDeps.push(name);
19041
19274
  pseudoClassesFromStyleSet.add(name);
19275
+ if (boxPseudoNamedStyles === PSEUDO_NAMED_STYLES_DEFAULT) {
19276
+ boxPseudoNamedStyles = {};
19277
+ }
19042
19278
  const pseudoStyleContext = {
19043
19279
  ...styleContext,
19044
19280
  styleCSSVars: {
@@ -19059,18 +19295,16 @@ const Box = props => {
19059
19295
  const pseudoClassStyles = {};
19060
19296
  for (const key of pseudoStyleKeys) {
19061
19297
  visitProp(value[key], key, pseudoStyleContext, pseudoClassStyles, "pseudo_style");
19062
- boxPseudoNamedStyles[name] = pseudoClassStyles;
19063
19298
  }
19299
+ boxPseudoNamedStyles[name] = pseudoClassStyles;
19064
19300
  return;
19065
19301
  }
19066
- const context = styleOrigin === "base_style" ? "js" : "css";
19067
- const isCss = styleOrigin === "base_style" || styleOrigin === "style";
19068
- if (isCss) {
19069
- addStyle(value, name, styleContext, boxStylesTarget, context);
19302
+ if (styleOrigin === "style") {
19303
+ addStyle(value, name, styleContext, boxStylesTarget);
19070
19304
  return;
19071
19305
  }
19072
19306
  if (name.startsWith("--")) {
19073
- addStyle(value, name, styleContext, boxStylesTarget, context);
19307
+ addStyle(value, name, styleContext, boxStylesTarget);
19074
19308
  return;
19075
19309
  }
19076
19310
  const isPseudoStyle = styleOrigin === "pseudo_style";
@@ -19082,7 +19316,7 @@ const Box = props => {
19082
19316
  if (
19083
19317
  // prop name === css style name
19084
19318
  !getStyle) {
19085
- const needForwarding = addStyleMaybeForwarding(value, name, styleContext, boxStylesTarget, context, visualChildPropStrategy);
19319
+ const needForwarding = addStyleMaybeForwarding(value, name, styleContext, boxStylesTarget, visualChildPropStrategy);
19086
19320
  if (needForwarding) {
19087
19321
  if (isPseudoStyle) ; else {
19088
19322
  childForwardedProps[name] = value;
@@ -19097,7 +19331,7 @@ const Box = props => {
19097
19331
  let needForwarding = false;
19098
19332
  for (const styleName of Object.keys(cssValues)) {
19099
19333
  const cssValue = cssValues[styleName];
19100
- needForwarding = addStyleMaybeForwarding(cssValue, styleName, styleContext, boxStylesTarget, context, visualChildPropStrategy);
19334
+ needForwarding = addStyleMaybeForwarding(cssValue, styleName, styleContext, boxStylesTarget, visualChildPropStrategy);
19101
19335
  }
19102
19336
  if (needForwarding) {
19103
19337
  if (isPseudoStyle) ; else {
@@ -19133,13 +19367,7 @@ const Box = props => {
19133
19367
  }
19134
19368
  return;
19135
19369
  };
19136
- if (baseStyle) {
19137
- for (const key of baseStyle) {
19138
- const value = baseStyle[key];
19139
- visitProp(value, key, styleContext, boxStyles, "baseStyle");
19140
- }
19141
- }
19142
- for (const propName of remainingPropKeySet) {
19370
+ for (const propName of Object.keys(rest)) {
19143
19371
  const propValue = rest[propName];
19144
19372
  if (baseChildPropSet?.has(propName) || childPropSet?.has(propName)) {
19145
19373
  if (canForwardToChild) {
@@ -19149,9 +19377,6 @@ const Box = props => {
19149
19377
  }
19150
19378
  continue;
19151
19379
  }
19152
- if (canForwardToChild && toCopySet.has(propName)) {
19153
- childForwardedProps[propName] = propValue;
19154
- }
19155
19380
  const isDataAttribute = propName.startsWith("data-");
19156
19381
  if (isDataAttribute) {
19157
19382
  selfForwardedProps[propName] = propValue;
@@ -19193,7 +19418,6 @@ const Box = props => {
19193
19418
  visitProp(styleValue, styleName, styleContext, boxStyles, "style");
19194
19419
  }
19195
19420
  }
19196
- styleDeps.push(pseudoStateSelector, innerPseudoState);
19197
19421
  let innerPseudoClasses;
19198
19422
  if (pseudoClassesFromStyleSet.size) {
19199
19423
  innerPseudoClasses = [...pseudoClasses];
@@ -19210,7 +19434,7 @@ const Box = props => {
19210
19434
  styleDeps.push(...pseudoClasses);
19211
19435
  }
19212
19436
  }
19213
- const syncBox = useCallback(boxEl => {
19437
+ syncBox = boxEl => {
19214
19438
  const pseudoStateEl = pseudoStateSelector ? boxEl.querySelector(pseudoStateSelector) : boxEl;
19215
19439
  if (!pseudoStateEl) {
19216
19440
  console.error(`pseudoStateSelector "${pseudoStateSelector}" did not match any element inside the box`, boxEl);
@@ -19225,42 +19449,24 @@ const Box = props => {
19225
19449
  elementToImpact: boxEl,
19226
19450
  elementListeningPseudoState: visualEl === pseudoStateEl ? null : visualEl
19227
19451
  });
19228
- }, styleDeps);
19229
- finalRef = useComposeElementRef(syncBox, ref);
19230
- }
19231
- useInteractionsEffect(finalRef, interactionsRef);
19232
- let innerChildren = children;
19233
- if (separator) {
19234
- // Flatten nested arrays (e.g., from .map()) to treat each element as individual child
19235
- innerChildren = applySeparatorOnChildren(innerChildren, separator);
19236
- }
19237
-
19238
- // When hasChildUsingForwardedProps is used it means
19239
- // Some/all the children needs to access remainingProps
19240
- // to render and will provide a function to do so.
19241
- if (hasChildUsingForwardedProps) {
19242
- innerChildren = jsx(BoxForwardedPropsContext.Provider, {
19243
- value: childForwardedProps,
19244
- children: innerChildren
19245
- });
19452
+ };
19246
19453
  }
19247
19454
  const aspectRatio = rest.square || rest.circle ? "1/1" : rest.aspectRatio;
19248
- return jsx(TagName, {
19249
- ref: finalRef,
19250
- className: innerClassName,
19251
- "navi-box-flow": boxFlowIsDefault ? undefined : boxFlow,
19252
- "navi-box-flow-row": row ? "" : undefined,
19253
- "navi-box-flow-column": column ? "" : undefined,
19254
- "navi-aspect-ratio": aspectRatio ? aspectRatio : undefined,
19255
- "data-visual-selector": visualSelector,
19256
- ...selfForwardedProps,
19257
- children: jsx(BoxFlowContext.Provider, {
19258
- value: boxFlow,
19259
- children: innerChildren
19260
- })
19261
- });
19455
+ return {
19456
+ TagName,
19457
+ boxFlow,
19458
+ boxFlowIsDefault,
19459
+ row,
19460
+ column,
19461
+ aspectRatio,
19462
+ visualSelector,
19463
+ innerClassName,
19464
+ selfForwardedProps,
19465
+ childForwardedProps,
19466
+ styleDeps,
19467
+ syncBox
19468
+ };
19262
19469
  };
19263
- const toCopySet = new Set([]);
19264
19470
  const applySeparatorOnChildren = (children, separator) => {
19265
19471
  const flattenedChildren = toChildArray(children);
19266
19472
  if (flattenedChildren.length <= 1) {
@@ -19306,6 +19512,62 @@ const isNonZeroSpacing = value => {
19306
19512
  }
19307
19513
  return true;
19308
19514
  };
19515
+ const isScrollingOverflow = value => value === "auto" || value === "scroll";
19516
+
19517
+ // Same props as far as computeBox is concerned. Handlers count by name only,
19518
+ // see withCurrentHandlers; no style or state key starts with "on".
19519
+ const arePropsEquivalent = (previousProps, props) => compareTwoJsValues(previousProps, props, {
19520
+ keyComparator: comparePropAt
19521
+ });
19522
+ const comparePropAt = (a, b, key, recurse) => {
19523
+ if (typeof key === "string" && key.startsWith("on")) {
19524
+ return true;
19525
+ }
19526
+ return recurse(a, b);
19527
+ };
19528
+ // The previous computation with this render's handlers: a handler goes where
19529
+ // its name went the previous time, self or child, the split being decided by
19530
+ // props that are the same. The forwarded objects keep their identity when no
19531
+ // handler changed, so what reads them through context sees nothing new.
19532
+ const withCurrentHandlers = (computed, props) => {
19533
+ let {
19534
+ selfForwardedProps,
19535
+ childForwardedProps
19536
+ } = computed;
19537
+ for (const key of Object.keys(props)) {
19538
+ if (!key.startsWith("on")) {
19539
+ continue;
19540
+ }
19541
+ const value = props[key];
19542
+ if (Object.hasOwn(childForwardedProps, key)) {
19543
+ if (childForwardedProps[key] !== value) {
19544
+ if (childForwardedProps === computed.childForwardedProps) {
19545
+ childForwardedProps = {
19546
+ ...childForwardedProps
19547
+ };
19548
+ }
19549
+ childForwardedProps[key] = value;
19550
+ }
19551
+ } else if (Object.hasOwn(selfForwardedProps, key)) {
19552
+ if (selfForwardedProps[key] !== value) {
19553
+ if (selfForwardedProps === computed.selfForwardedProps) {
19554
+ selfForwardedProps = {
19555
+ ...selfForwardedProps
19556
+ };
19557
+ }
19558
+ selfForwardedProps[key] = value;
19559
+ }
19560
+ }
19561
+ }
19562
+ if (selfForwardedProps === computed.selfForwardedProps && childForwardedProps === computed.childForwardedProps) {
19563
+ return computed;
19564
+ }
19565
+ return {
19566
+ ...computed,
19567
+ selfForwardedProps,
19568
+ childForwardedProps
19569
+ };
19570
+ };
19309
19571
 
19310
19572
  const useDebounceTrue = (value, delay = 300) => {
19311
19573
  const [debouncedTrue, setDebouncedTrue] = useState(false);
@@ -19419,7 +19681,7 @@ const setupNetworkMonitoring = () => {
19419
19681
  };
19420
19682
  setupNetworkMonitoring();
19421
19683
 
19422
- installImportMetaCssBuild(import.meta);const css$12 = /* css */`
19684
+ installImportMetaCssBuild(import.meta);const css$11 = /* css */`
19423
19685
  .navi_loading_indicator_fluid_container {
19424
19686
  position: relative;
19425
19687
  display: flex;
@@ -19451,7 +19713,7 @@ const LoadingIndicatorFluid = ({
19451
19713
  visuallyHidden,
19452
19714
  ...rest
19453
19715
  }) => {
19454
- import.meta.css = [css$12, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
19716
+ import.meta.css = [css$11, "@jsenv/navi/src/graphic/loading/loading_indicator_fluid.jsx"];
19455
19717
  const ref = useRef(null);
19456
19718
  // The container dimensions can be deduced from the ref itself as the indicator is absolute inset 0
19457
19719
  const [containerWidth, setContainerWidth] = useState(0);
@@ -19656,7 +19918,7 @@ const LoadingRectangleSvg = ({
19656
19918
  });
19657
19919
  };
19658
19920
 
19659
- installImportMetaCssBuild(import.meta);const css$11 = /* css */`
19921
+ installImportMetaCssBuild(import.meta);const css$10 = /* css */`
19660
19922
  .navi_loading_outline_wrapper {
19661
19923
  position: absolute;
19662
19924
  /* Controls place the outline slightly outside their box, right on top of
@@ -19693,7 +19955,7 @@ installImportMetaCssBuild(import.meta);const css$11 = /* css */`
19693
19955
  }
19694
19956
  `;
19695
19957
  const LoadingOutline = props => {
19696
- import.meta.css = [css$11, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
19958
+ import.meta.css = [css$10, "@jsenv/navi/src/graphic/loading/loading_outline.jsx"];
19697
19959
  if (props.containerRef) {
19698
19960
  const container = props.containerRef.current;
19699
19961
  if (!container) {
@@ -19883,6 +20145,256 @@ const getHrefTargetInfo = (href) => {
19883
20145
  };
19884
20146
  };
19885
20147
 
20148
+ /**
20149
+ * A variant of useLayoutEffect that accounts for ancestor <dialog>/<details>
20150
+ * or popover visibility.
20151
+ *
20152
+ * Motivation: some effects (auto-scroll, measurement, focus) only make sense
20153
+ * when the element is actually presented on screen. A plain useLayoutEffect
20154
+ * fires on mount even when the component is inside a closed <dialog>, a
20155
+ * collapsed <details>, or a hidden popover, where scroll and layout operations
20156
+ * are no-ops.
20157
+ *
20158
+ * Behavior:
20159
+ * - No <dialog>/<details>/[popover] ancestor → runs like a normal
20160
+ * useLayoutEffect with the provided deps.
20161
+ * - Inside a closed/hidden ancestor → skips the initial run; instead runs
20162
+ * the callback once the ancestor opens — see @jsenv/dom's own
20163
+ * observeAncestorOpenState for exactly how that's detected, and why it
20164
+ * matters that it happens before the browser paints.
20165
+ * - Inside an open ancestor → runs on mount AND every subsequent open.
20166
+ *
20167
+ * The callback's second argument is always a `navi_displayed` CustomEvent,
20168
+ * with `detail: { ancestor, ancestorType, becauseAncestorOpened }`:
20169
+ * - No <dialog>/<details>/[popover]/[aria-expanded] ancestor at all →
20170
+ * `{ ancestor: document, ancestorType: "document" }`.
20171
+ * - Otherwise → `{ ancestor: <the matched element>, ancestorType: "dialog"
20172
+ * | "popover" | "details" | "aria-expanded" }`.
20173
+ * `becauseAncestorOpened` distinguishes the two ways of coming on screen:
20174
+ * - true — the element was already mounted and the ancestor just opened,
20175
+ * revealing it along with everything else it holds. The opening has an
20176
+ * owner (the ancestor's own transferFocus/openEffect), and what it reveals
20177
+ * should defer to it — see use_auto_focus.js.
20178
+ * - false — the element was mounted just now, into a surface already on
20179
+ * screen (or into the plain document). Nothing else owns this appearance:
20180
+ * what the element says about itself (an autofocus, a measurement) is the
20181
+ * only word there is.
20182
+ *
20183
+ * Usage:
20184
+ * useDisplayedLayoutEffect(ref, () => {
20185
+ * scrollToSelected();
20186
+ * }, []);
20187
+ */
20188
+ const useDisplayedLayoutEffect = (ref, callback, deps) => {
20189
+ if (typeof callback !== "function") {
20190
+ throw new TypeError("useDisplayedLayoutEffect: callback is not a function");
20191
+ }
20192
+
20193
+ // Keep a stable ref so the open listener always calls the latest callback
20194
+ // without needing to be re-registered when deps change.
20195
+ const callbackRef = useRef(callback);
20196
+ callbackRef.current = callback;
20197
+
20198
+ // Run on mount (or when deps change) — but only if the element is visible.
20199
+ useLayoutEffect(() => {
20200
+ const el = ref.current;
20201
+ if (!el) {
20202
+ return;
20203
+ }
20204
+ const ancestor = closestOpenableAncestor(el);
20205
+ if (!ancestor) {
20206
+ callbackRef.current(el, createDisplayedEvent(document, false));
20207
+ return;
20208
+ }
20209
+ if (!isAncestorOpen(ancestor)) {
20210
+ // Ancestor is closed — skip now; the observeAncestorOpenState call
20211
+ // below will fire once it opens.
20212
+ return;
20213
+ }
20214
+ callbackRef.current(el, createDisplayedEvent(ancestor, false));
20215
+ }, deps);
20216
+
20217
+ // Re-run every time the ancestor opens.
20218
+ useLayoutEffect(() => {
20219
+ const el = ref.current;
20220
+ if (!el) {
20221
+ return undefined;
20222
+ }
20223
+ const ancestor = closestOpenableAncestor(el);
20224
+ if (!ancestor) {
20225
+ return undefined;
20226
+ }
20227
+ return observeAncestorOpenState(ancestor, ({ isOpen }) => {
20228
+ if (!isOpen) {
20229
+ return;
20230
+ }
20231
+ const lastEl = ref.current;
20232
+ callbackRef.current(lastEl, createDisplayedEvent(ancestor, true));
20233
+ });
20234
+ }, []);
20235
+ };
20236
+
20237
+ const createDisplayedEvent = (ancestor, becauseAncestorOpened) => {
20238
+ return new CustomEvent("navi_displayed", {
20239
+ detail: {
20240
+ ancestor,
20241
+ ancestorType: getAncestorOpenType(ancestor),
20242
+ becauseAncestorOpened,
20243
+ },
20244
+ });
20245
+ };
20246
+
20247
+ installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
20248
+ const css$$ = /* css */`
20249
+ .navi_text_anchor {
20250
+ vertical-align: baseline;
20251
+ user-select: none;
20252
+ overflow: hidden;
20253
+ }
20254
+ `;
20255
+
20256
+ /**
20257
+ * Positions children vertically relative to the surrounding text, correcting for font-size differences.
20258
+ *
20259
+ * Place this component around any inline element whose font-size differs from the surrounding text.
20260
+ * It renders an invisible anchor that inherits the surrounding text's font metrics, then shifts
20261
+ * the child so that its visual position matches the requested `textAnchor` value — regardless of
20262
+ * font-size, display type (inline, inline-block, inline-flex…), or the active `vertical-align`.
20263
+ *
20264
+ * @param {"line-top"|"char-top"|"center"|"char-bottom"|"line-bottom"} [textAnchor="char-bottom"]
20265
+ * - `"line-top"` — child top aligns with the top of the surrounding line box
20266
+ * - `"char-top"` — child top aligns with the top of visible characters (ink ascent)
20267
+ * - `"center"` — child is vertically centered on the surrounding line box
20268
+ * - `"char-bottom"` — child bottom aligns to the text baseline (no correction, browser default)
20269
+ * - `"line-bottom"` — child bottom aligns with the bottom of the surrounding line box
20270
+ * @param {{ size?: number, verticalAlign?: string }} [lineLayout]
20271
+ * Describes the surrounding line context. Used as layout-effect dependencies so the correction
20272
+ * reruns when the surrounding text's font-size or vertical-align changes.
20273
+ * @param {import("ignore:preact").RefObject} childRef — ref on the child element to reposition
20274
+ */
20275
+ const TextAnchor = ({
20276
+ childRef,
20277
+ children,
20278
+ textAnchor = "char-bottom",
20279
+ textKey,
20280
+ textSize,
20281
+ lineLayout
20282
+ }) => {
20283
+ import.meta.css = [css$$, "@jsenv/navi/src/text/text_anchor.jsx"];
20284
+ const anchorRef = useRef();
20285
+
20286
+ // Plain useLayoutEffect would also fire while an ancestor dialog/popover
20287
+ // (e.g. a closed SidePanel) is still display:none — every rect involved
20288
+ // reads 0×0 at that point, so the math trivially (and wrongly) resolves
20289
+ // to topOffset 0: not a real "no correction needed" result, just a
20290
+ // zero-by-zero coincidence that happens to look fine only because it
20291
+ // leaves the browser's own default alignment untouched. The real
20292
+ // correction then only gets applied later, on whatever unrelated
20293
+ // re-render next happens to change one of this effect's own deps —
20294
+ // which reads as the child "jumping" even though nothing about its own
20295
+ // geometry changed. useDisplayedLayoutEffect skips the initial run in
20296
+ // that case and reruns once the ancestor actually opens instead.
20297
+ useDisplayedLayoutEffect(anchorRef, anchorEl => {
20298
+ const childEl = childRef.current;
20299
+ if (!anchorEl || !childEl) {
20300
+ return;
20301
+ }
20302
+ // Only correct when the anchor lives in an inline formatting context.
20303
+ // If the parent is a flex/grid container, inline layout rules don't apply
20304
+ // and our font-metrics model is invalid.
20305
+ const parentDisplay = getComputedStyle(anchorEl.parentElement).display;
20306
+ if (parentDisplay !== "inline" && parentDisplay !== "inline-block" && parentDisplay !== "block") {
20307
+ // we must hide the anchor otherwise it would affect layout without providing any benefit (would trigger flex gap for instance)
20308
+ anchorEl.setAttribute("hidden", "");
20309
+ setTopOffset(childEl, 0);
20310
+ return;
20311
+ }
20312
+ anchorEl.removeAttribute("hidden");
20313
+ const topOffset = computeTopOffset({
20314
+ anchorEl,
20315
+ childEl,
20316
+ textAnchor
20317
+ });
20318
+ setTopOffset(childEl, topOffset);
20319
+ }, [textAnchor, textKey, textSize, lineLayout?.size, lineLayout?.verticalAlign]);
20320
+ return jsxs(Fragment$1, {
20321
+ children: [children, jsx("span", {
20322
+ ref: anchorRef,
20323
+ className: "navi_text_anchor",
20324
+ "aria-hidden": "true",
20325
+ children: "\u200B"
20326
+ })]
20327
+ });
20328
+ };
20329
+ const setTopOffset = (childEl, topOffset) => {
20330
+ // position:relative + top shifts the element visually.
20331
+ // marginTop: -topOffset makes the layout box follow the visual position, so any container
20332
+ // (button, link, box…) computes its own padding/border/height based on the real final position
20333
+ // rather than the original unshifted one. This means a badge inside a button will symmetrically
20334
+ // expand the button height instead of overflowing or being clipped.
20335
+ // marginBottom: topOffset compensates the marginTop so the line height stays unchanged —
20336
+ // the shift is purely a repositioning, not an inflation of the line.
20337
+ if (!topOffset) {
20338
+ childEl.style.position = "";
20339
+ childEl.style.top = "";
20340
+ childEl.style.marginTop = "";
20341
+ childEl.style.marginBottom = "";
20342
+ return;
20343
+ }
20344
+ childEl.style.position = "relative";
20345
+ childEl.style.top = `${topOffset}px`;
20346
+ childEl.style.marginTop = `${-topOffset}px`;
20347
+ childEl.style.marginBottom = `${topOffset}px`;
20348
+ };
20349
+ const computeTopOffset = ({
20350
+ anchorEl,
20351
+ childEl,
20352
+ textAnchor
20353
+ }) => {
20354
+ if (textAnchor === "char-bottom") {
20355
+ // Align child's bottom with the char's bottom = the baseline.
20356
+ // The CSS spec says an inline-block with no text content has its baseline at its bottom margin edge.
20357
+ // So the browser's default placement already puts the child's bottom at the line's baseline.
20358
+ // No correction needed.
20359
+ return 0;
20360
+ }
20361
+ // The anchor's rendered rect corresponds to the surrounding text's line box:
20362
+ // top and bottom are the visual bounds of the line (including line-height).
20363
+ const anchorRect = anchorEl.getBoundingClientRect();
20364
+
20365
+ // Measure the child's current rect, then subtract any previously applied top correction
20366
+ // to recover its natural position — avoiding a style reset + reflow.
20367
+ const childRect = childEl.getBoundingClientRect();
20368
+ const childH = childRect.height;
20369
+ const previousTop = parseFloat(childEl.style.top) || 0;
20370
+ const childNaturalTop = childRect.top - previousTop;
20371
+
20372
+ // Compute desired child top Y based on textAnchor intention.
20373
+ let desiredChildTopY = 0;
20374
+ if (textAnchor === "line-top") {
20375
+ desiredChildTopY = anchorRect.top;
20376
+ } else if (textAnchor === "char-top") {
20377
+ const anchorStyle = getComputedStyle(anchorEl);
20378
+ const ctx = charTopCanvas.getContext("2d");
20379
+ ctx.font = `${anchorStyle.fontWeight} ${anchorStyle.fontSize} ${anchorStyle.fontFamily}`;
20380
+ const m = ctx.measureText("M");
20381
+ const baselineY = anchorRect.bottom - m.fontBoundingBoxDescent;
20382
+ desiredChildTopY = baselineY - m.actualBoundingBoxAscent;
20383
+ } else if (textAnchor === "center") {
20384
+ const anchorCenterY = (anchorRect.top + anchorRect.bottom) / 2;
20385
+ desiredChildTopY = anchorCenterY - childH / 2;
20386
+ } else if (textAnchor === "char-bottom") {
20387
+ // Already handled above (early return 0), but guard here for completeness.
20388
+ return 0;
20389
+ } else if (textAnchor === "line-bottom") {
20390
+ desiredChildTopY = anchorRect.bottom - childH;
20391
+ } else {
20392
+ return 0; // unknown textAnchor, no correction
20393
+ }
20394
+ return desiredChildTopY - childNaturalTop;
20395
+ };
20396
+ const charTopCanvas = document.createElement("canvas");
20397
+
19886
20398
  const useInitialTextSelection = (ref, textSelection) => {
19887
20399
  const deps = [];
19888
20400
  if (Array.isArray(textSelection)) {
@@ -19983,7 +20495,7 @@ const selectByTextStrings = (element, range, startText, endText) => {
19983
20495
  };
19984
20496
 
19985
20497
  installImportMetaCssBuild(import.meta);// https://jsfiddle.net/v5xzJ/4/
19986
- const css$10 = /* css */`
20498
+ const css$_ = /* css */`
19987
20499
  @layer navi {
19988
20500
  .navi_text {
19989
20501
  &[data-skeleton] {
@@ -20167,6 +20679,91 @@ const css$10 = /* css */`
20167
20679
  opacity: 1;
20168
20680
  }
20169
20681
  }
20682
+
20683
+ /* ── Icon ── */
20684
+
20685
+ @layer navi {
20686
+ /* Ensure data attributes from box.jsx can win to update display */
20687
+ .navi_icon {
20688
+ display: inline-flex;
20689
+ box-sizing: border-box;
20690
+ max-width: 100%;
20691
+ /* An icon never grows past the box it sits in, so a glyph can never make
20692
+ a line of text taller than the text itself. lineOverflow="allow" opts
20693
+ out, for an icon that is an affordance rather than a character — a
20694
+ control's chevron or clear button, sized to be touched, not read. */
20695
+ max-height: 100%;
20696
+
20697
+ &[data-line-overflow="allow"] {
20698
+ max-height: none;
20699
+ }
20700
+ }
20701
+ }
20702
+
20703
+ .navi_icon {
20704
+ white-space: nowrap;
20705
+ vertical-align: inherit;
20706
+
20707
+ &[data-icon-char] {
20708
+ aspect-ratio: 1/1;
20709
+ min-width: 0;
20710
+ height: round(1em, 1px);
20711
+ max-height: round(1em, 1px);
20712
+ flex-grow: 0 !important;
20713
+ align-items: center;
20714
+ justify-content: center;
20715
+
20716
+ /* fillLine: measured on the line box (1lh) instead of the character box
20717
+ (1em). The icon still stays inside the line — it just uses all of it,
20718
+ which is what an icon standing on its own in a control's slot wants,
20719
+ where a glyph sitting among letters wants to match their size. */
20720
+ &[data-fill-line] {
20721
+ height: round(1lh, 1px);
20722
+ max-height: round(1lh, 1px);
20723
+ }
20724
+
20725
+ svg,
20726
+ img {
20727
+ width: 100%;
20728
+ height: 100%;
20729
+ }
20730
+ svg {
20731
+ overflow: visible;
20732
+ }
20733
+ }
20734
+ &[data-flow-inline] {
20735
+ width: 1em;
20736
+ height: 1em;
20737
+ }
20738
+ &[data-interactive] {
20739
+ cursor: pointer;
20740
+ }
20741
+ &[data-icon-text] {
20742
+ -webkit-font-smoothing: antialiased;
20743
+ text-rendering: optimizeLegibility;
20744
+ }
20745
+ }
20746
+
20747
+ .navi_icon > svg,
20748
+ .navi_icon > img {
20749
+ width: 100%;
20750
+ height: 100%;
20751
+ }
20752
+ .navi_icon[data-width-fixed] > svg,
20753
+ .navi_icon[data-width-fixed] > img {
20754
+ width: 100%;
20755
+ height: auto;
20756
+ }
20757
+ .navi_icon[data-height-fixed] > svg,
20758
+ .navi_icon[data-height-fixed] > img {
20759
+ width: auto;
20760
+ height: 100%;
20761
+ }
20762
+ .navi_icon[data-width-fixed][data-height-fixed] > svg,
20763
+ .navi_icon[data-width-fixed][data-height-fixed] > img {
20764
+ width: 100%;
20765
+ height: 100%;
20766
+ }
20170
20767
  `;
20171
20768
  const REGULAR_SPACE = jsx("span", {
20172
20769
  "data-navi-space": "",
@@ -20349,6 +20946,7 @@ const shouldInjectSpacingBetween = (left, right) => {
20349
20946
  * loading?: boolean,
20350
20947
  * skeleton?: boolean,
20351
20948
  * attachLastChild?: boolean,
20949
+ * emojiAsIcon?: boolean,
20352
20950
  * preventSpaceUnderlines?: boolean,
20353
20951
  * holdSpaceForStyle?: import("ignore:preact").JSX.CSSProperties,
20354
20952
  * boldStable?: boolean,
@@ -20388,6 +20986,12 @@ const shouldInjectSpacingBetween = (left, right) => {
20388
20986
  * truncation belongs outside the `Text` instead (see `docs/typography.md`).
20389
20987
  * `Link` sets it on its own whenever it renders an end icon.
20390
20988
  *
20989
+ * @param {boolean} [emojiAsIcon]
20990
+ * Renders every emoji found in the string children as an `Icon`, so it sits
20991
+ * in the line like a character and never makes the line taller than the
20992
+ * text. For free text a user typed (a message, a description) — the only
20993
+ * place an emoji is expected; see `docs/typography.md`.
20994
+ *
20391
20995
  * @param {boolean} [preventSpaceUnderlines]
20392
20996
  * Replaces real space characters between children with padding-based spaces.
20393
20997
  * Useful inside `<a>` elements where browsers draw an underline under spaces.
@@ -20421,14 +21025,6 @@ const shouldInjectSpacingBetween = (left, right) => {
20421
21025
  * internally for overlays such as the skeleton container.
20422
21026
  */
20423
21027
  const Text = props => {
20424
- const defaultRef = useRef();
20425
- const ref = props.ref || defaultRef;
20426
- return jsx(TextDispatcher, {
20427
- ...props,
20428
- ref: ref
20429
- });
20430
- };
20431
- const TextDispatcher = props => {
20432
21028
  if (props.loading || props.skeleton) {
20433
21029
  return jsx(TextSkeleton, {
20434
21030
  ...props
@@ -20454,9 +21050,8 @@ const TextDispatcher = props => {
20454
21050
  });
20455
21051
  };
20456
21052
  const TextShrinkWrap = props => {
20457
- const {
20458
- ref
20459
- } = props;
21053
+ const defaultRef = useRef();
21054
+ const ref = props.ref || defaultRef;
20460
21055
  const applyWidth = () => {
20461
21056
  const text = ref.current;
20462
21057
  // Reset any previously forced width so we measure the natural size
@@ -20497,19 +21092,21 @@ const TextShrinkWrap = props => {
20497
21092
  window.removeEventListener("resize", applyWidth);
20498
21093
  };
20499
21094
  }, []);
20500
- return jsx(TextDispatcher, {
21095
+ return jsx(Text, {
20501
21096
  ...props,
21097
+ ref: ref,
20502
21098
  "data-shrinkwrap": "",
20503
21099
  shrinkWrap: undefined
20504
21100
  });
20505
21101
  };
20506
21102
  const TextUI = props => {
20507
- import.meta.css = [css$10, "@jsenv/navi/src/text/text.jsx"];
21103
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
20508
21104
  let {
20509
21105
  ref,
20510
21106
  spacing,
20511
21107
  preventSpaceUnderlines = false,
20512
21108
  attachLastChild = false,
21109
+ emojiAsIcon = false,
20513
21110
  boldStable,
20514
21111
  holdSpaceForStyle,
20515
21112
  capitalize,
@@ -20540,6 +21137,11 @@ const TextUI = props => {
20540
21137
  } else {
20541
21138
  children = applySpacingOnTextChildren(children, resolvedSpacing, defaultSpace);
20542
21139
  }
21140
+ if (emojiAsIcon) {
21141
+ // After the spacing pass: an emoji glued to a word ("hello👋") must not
21142
+ // get a separator injected as if it were a child element.
21143
+ children = renderEmojiAsIcon(children);
21144
+ }
20543
21145
  if (boldStable) {
20544
21146
  const {
20545
21147
  bold
@@ -20608,7 +21210,7 @@ const TextSkeleton = ({
20608
21210
  "aria-hidden": "true",
20609
21211
  children: "W"
20610
21212
  });
20611
- return jsx(TextDispatcher, {
21213
+ return jsx(Text, {
20612
21214
  "data-skeleton": "",
20613
21215
  "data-loading": loading ? "" : undefined,
20614
21216
  ...props,
@@ -20624,7 +21226,7 @@ const TextOverflow = ({
20624
21226
  children,
20625
21227
  ...rest
20626
21228
  }) => {
20627
- return jsx(TextDispatcher, {
21229
+ return jsx(Text, {
20628
21230
  block: true,
20629
21231
  as: "div",
20630
21232
  pre: noWrap === undefined ? true : undefined
@@ -20646,14 +21248,254 @@ const TextWithSelectRange = ({
20646
21248
  selectRange,
20647
21249
  ...props
20648
21250
  }) => {
20649
- useInitialTextSelection(ref, selectRange);
20650
- return jsx(TextDispatcher, {
21251
+ const defaultRef = useRef();
21252
+ const innerRef = ref || defaultRef;
21253
+ useInitialTextSelection(innerRef, selectRange);
21254
+ return jsx(Text, {
20651
21255
  ...props,
20652
- ref: ref,
21256
+ ref: innerRef,
20653
21257
  selectRange: undefined
20654
21258
  });
20655
21259
  };
20656
21260
 
21261
+ /**
21262
+ * Renders an icon — an inline SVG/emoji/text glyph that inherits the
21263
+ * surrounding text's `currentColor` and (by default) its font size, so it sits
21264
+ * on the text baseline like a character.
21265
+ *
21266
+ * Content comes from either `href` (references an external/sprite symbol via
21267
+ * `<use>`) or `children` (an inline `<svg>` element, or a string for a
21268
+ * text/emoji icon). All extra props are spread onto the underlying `Box`/`Text`
21269
+ * (sizing, spacing, color, className, data-attributes, …).
21270
+ *
21271
+ * Render mode is chosen automatically:
21272
+ * - `children` is a **string** → a text icon (`<Text data-icon-text>`).
21273
+ * - **sized** (an explicit `width`/`height`, or `flex`/`grid`) → a block icon
21274
+ * (`<Box square>`), laid out as its own box rather than inline.
21275
+ * - otherwise → an **inline char-like** icon that flows on the text baseline
21276
+ * (`data-icon-char`), aligned via `textAnchor`.
21277
+ *
21278
+ * Accessibility: an icon is treated as decorative (`aria-hidden`) by default
21279
+ * whenever it has no explicit size and no `onClick`; give it an explicit
21280
+ * `decorative={false}` (or make it interactive) when it conveys meaning.
21281
+ *
21282
+ * @param {object} props
21283
+ * @param {string} [props.href] - URL/id of an external SVG symbol, rendered via
21284
+ * `<svg><use href></svg>`. Mutually exclusive with meaningful `children`.
21285
+ * @param {import("ignore:preact").ComponentChildren} [props.children] - Inline icon
21286
+ * content: an `<svg>` element, or a string (renders as a text/emoji icon).
21287
+ * @param {boolean} [props.decorative] - Marks the icon `aria-hidden`. Defaults
21288
+ * to `true` for an unsized, non-interactive icon; pass `false` for a
21289
+ * meaning-bearing icon that needs to be exposed to assistive tech.
21290
+ * @param {(event: MouseEvent) => void} [props.onClick] - Makes the icon
21291
+ * interactive (`data-interactive`, pointer cursor) and non-decorative.
21292
+ * @param {"line-top"|"char-top"|"center"|"char-bottom"|"line-bottom"} [props.textAnchor="center"]
21293
+ * - Vertical alignment within the surrounding text line for the inline
21294
+ * char-like mode, forwarded to `TextAnchor`: `"line-top"`/`"line-bottom"`
21295
+ * align to the line box edges, `"char-top"` to the ink ascent, `"center"`
21296
+ * centers on the line box, `"char-bottom"` sits on the baseline. See
21297
+ * `text_anchor.jsx`.
21298
+ * @param {{ size?: number, verticalAlign?: string }} [props.lineLayout] -
21299
+ * Describes the surrounding line context (font size / vertical-align),
21300
+ * forwarded to `TextAnchor` so it recomputes the vertical correction when
21301
+ * that context changes.
21302
+ * @param {string|number} [props.width] - Explicit width; `"auto"` clears it.
21303
+ * Any explicit size switches the icon to block (sized) mode.
21304
+ * @param {string|number} [props.height] - Explicit height; `"auto"` clears it.
21305
+ * @param {"allow"} [props.lineOverflow] - `"allow"` lets the icon be taller
21306
+ * than the box it sits in (a line of text, a control's slot) instead of being
21307
+ * capped by it. For an icon that is an affordance sized for the finger rather
21308
+ * than a character sized for reading.
21309
+ * @param {boolean} [props.fillLine] - Sizes the icon on the line box (1lh)
21310
+ * rather than on the character box (1em), so it uses the full height of the
21311
+ * line without leaving it. Unlike `lineOverflow`, the icon still never
21312
+ * exceeds the line.
21313
+ * @param {boolean} [props.square] - Keep a 1:1 box; combined with one explicit
21314
+ * dimension it fixes the other too.
21315
+ * @param {boolean} [props.circle] - Like `square`, plus a circular shape.
21316
+ * @param {string|number} [props.aspectRatio] - Fixes the second dimension from
21317
+ * the one explicit dimension.
21318
+ * @param {"x"|"y"|boolean} [props.flex] - Forces block/flex layout; auto-set to
21319
+ * `"x"` when the icon is sized.
21320
+ * @param {boolean} [props.grid] - Forces block/grid layout.
21321
+ * @param {string} [props.className] - Merged with the base `"navi_icon"` class.
21322
+ */
21323
+ const Icon = ({
21324
+ href,
21325
+ children,
21326
+ decorative,
21327
+ onClick,
21328
+ textAnchor = "center",
21329
+ lineLayout,
21330
+ lineOverflow,
21331
+ fillLine,
21332
+ ...props
21333
+ }) => {
21334
+ import.meta.css = [css$_, "@jsenv/navi/src/text/text.jsx"];
21335
+ const innerChildren = href ? jsx("svg", {
21336
+ width: "100%",
21337
+ height: "100%",
21338
+ children: jsx("use", {
21339
+ href: href
21340
+ })
21341
+ }) : children;
21342
+ let {
21343
+ flex,
21344
+ grid,
21345
+ width,
21346
+ height
21347
+ } = props;
21348
+ if (width === "auto") {
21349
+ width = undefined;
21350
+ }
21351
+ if (height === "auto") {
21352
+ height = undefined;
21353
+ }
21354
+ const hasExplicitWidth = width !== undefined;
21355
+ const hasExplicitHeight = height !== undefined;
21356
+ const widthFixed = hasExplicitWidth || hasExplicitHeight && (props.square || props.circle || props.aspectRatio);
21357
+ const heightFixed = hasExplicitHeight || hasExplicitWidth && (props.square || props.circle || props.aspectRatio);
21358
+ if (widthFixed || heightFixed) {
21359
+ if (flex === undefined) {
21360
+ flex = "x";
21361
+ }
21362
+ } else if (decorative === undefined && !onClick) {
21363
+ decorative = true;
21364
+ }
21365
+ const ariaProps = decorative ? {
21366
+ "aria-hidden": "true"
21367
+ } : {};
21368
+ const textRef = useRef();
21369
+ if (typeof children === "string") {
21370
+ return jsx(Text, {
21371
+ ...props,
21372
+ ...ariaProps,
21373
+ "data-icon-text": "",
21374
+ "data-line-overflow": lineOverflow,
21375
+ "data-fill-line": fillLine ? "" : undefined,
21376
+ children: children
21377
+ });
21378
+ }
21379
+ if (flex || grid) {
21380
+ return jsx(Box, {
21381
+ square: true,
21382
+ ...props,
21383
+ ...ariaProps,
21384
+ flex: flex,
21385
+ baseClassName: "navi_icon",
21386
+ "data-width-fixed": widthFixed ? "" : undefined,
21387
+ "data-height-fixed": heightFixed ? "" : undefined,
21388
+ "data-interactive": onClick ? "" : undefined,
21389
+ "data-line-overflow": lineOverflow,
21390
+ "data-fill-line": fillLine ? "" : undefined,
21391
+ onClick: onClick,
21392
+ children: innerChildren
21393
+ });
21394
+ }
21395
+ return jsx(TextAnchor, {
21396
+ childRef: textRef,
21397
+ textAnchor: textAnchor,
21398
+ textSize: props.size,
21399
+ lineLayout: lineLayout,
21400
+ children: jsxs(Text, {
21401
+ ...props,
21402
+ ...ariaProps,
21403
+ className: withPropsClassName("navi_icon", props.className),
21404
+ spacing: "pre",
21405
+ "data-icon-char": "",
21406
+ "data-line-overflow": lineOverflow,
21407
+ "data-fill-line": fillLine ? "" : undefined,
21408
+ "data-width-fixed": widthFixed ? "" : undefined,
21409
+ "data-height-fixed": heightFixed ? "" : undefined,
21410
+ "data-interactive": onClick ? "" : undefined,
21411
+ onClick: onClick,
21412
+ ref: textRef,
21413
+ children: [jsx("span", {
21414
+ style: "user-select:none",
21415
+ children: "\u200B"
21416
+ }), innerChildren]
21417
+ })
21418
+ });
21419
+ };
21420
+
21421
+ // An emoji-presentation character (🌸), or a pictogram forced into emoji
21422
+ // presentation by VS16 (❤️), followed by its skin-tone modifiers and ZWJ
21423
+ // sequence. A flag is two regional indicators that must stay together.
21424
+ const EMOJI_REGEX = /\p{Regional_Indicator}{2}|(?:\p{Emoji_Presentation}|[\p{Extended_Pictographic}--\p{Emoji_Presentation}]\uFE0F)(?:[\p{Emoji_Modifier}\uFE0F]|\u200D\p{Extended_Pictographic}\uFE0F?)*/gv;
21425
+
21426
+ /**
21427
+ * What `emojiAsIcon` does, for something that renders text without going
21428
+ * through `Text` (a callout's message): every emoji in the string children
21429
+ * comes back wrapped in an `Icon`. The system emoji fonts have a taller
21430
+ * ascent/descent than text fonts, so a raw emoji glyph makes its line taller
21431
+ * than the lines around it; inside an Icon it is capped at 1em and centered on
21432
+ * the line like any glyph icon.
21433
+ *
21434
+ * Children come back in the shape they arrived in: a string stays a string
21435
+ * when it holds no emoji, and the array is only built once a child actually
21436
+ * needs rewriting.
21437
+ */
21438
+ const renderEmojiAsIcon = children => {
21439
+ if (typeof children === "string") {
21440
+ return renderEmojiInString(children);
21441
+ }
21442
+ const childArray = toChildArray(children);
21443
+ let result = null;
21444
+ let index = 0;
21445
+ for (const child of childArray) {
21446
+ if (typeof child === "string") {
21447
+ const rendered = renderEmojiInString(child);
21448
+ if (rendered !== child) {
21449
+ if (result === null) {
21450
+ result = childArray.slice(0, index);
21451
+ }
21452
+ for (const part of rendered) {
21453
+ result.push(part);
21454
+ }
21455
+ index++;
21456
+ continue;
21457
+ }
21458
+ }
21459
+ if (result !== null) {
21460
+ result.push(child);
21461
+ }
21462
+ index++;
21463
+ }
21464
+ if (result === null) {
21465
+ return children;
21466
+ }
21467
+ return result;
21468
+ };
21469
+ const renderEmojiInString = string => {
21470
+ let parts = null;
21471
+ let lastIndex = 0;
21472
+ for (const match of string.matchAll(EMOJI_REGEX)) {
21473
+ if (parts === null) {
21474
+ parts = [];
21475
+ }
21476
+ if (match.index > lastIndex) {
21477
+ parts.push(string.slice(lastIndex, match.index));
21478
+ }
21479
+ parts.push(jsx(Icon, {
21480
+ decorative: false,
21481
+ children: jsx("span", {
21482
+ children: match[0]
21483
+ })
21484
+ }));
21485
+ lastIndex = match.index + match[0].length;
21486
+ }
21487
+ if (parts === null) {
21488
+ return string;
21489
+ }
21490
+ if (lastIndex < string.length) {
21491
+ parts.push(string.slice(lastIndex));
21492
+ }
21493
+ return parts;
21494
+ };
21495
+ // A callout message is free text like any other; see callout.jsx for why it
21496
+ // cannot import this itself.
21497
+ setCalloutMessageTextRenderer(renderEmojiAsIcon);
21498
+
20657
21499
  const LIGHT_ACCENT_ATTRIBUTE = "data-accent-light";
20658
21500
  const VERY_LIGHT_ACCENT_ATTRIBUTE = "data-accent-very-light";
20659
21501
  const DARK_CONTRAST_ATTRIBUTE = "data-accent-needs-dark-fg";
@@ -20797,105 +21639,6 @@ const useActionStatus = (action) => {
20797
21639
  };
20798
21640
  };
20799
21641
 
20800
- /**
20801
- * A variant of useLayoutEffect that accounts for ancestor <dialog>/<details>
20802
- * or popover visibility.
20803
- *
20804
- * Motivation: some effects (auto-scroll, measurement, focus) only make sense
20805
- * when the element is actually presented on screen. A plain useLayoutEffect
20806
- * fires on mount even when the component is inside a closed <dialog>, a
20807
- * collapsed <details>, or a hidden popover, where scroll and layout operations
20808
- * are no-ops.
20809
- *
20810
- * Behavior:
20811
- * - No <dialog>/<details>/[popover] ancestor → runs like a normal
20812
- * useLayoutEffect with the provided deps.
20813
- * - Inside a closed/hidden ancestor → skips the initial run; instead runs
20814
- * the callback once the ancestor opens — see @jsenv/dom's own
20815
- * observeAncestorOpenState for exactly how that's detected, and why it
20816
- * matters that it happens before the browser paints.
20817
- * - Inside an open ancestor → runs on mount AND every subsequent open.
20818
- *
20819
- * The callback's second argument is always a `navi_displayed` CustomEvent,
20820
- * with `detail: { ancestor, ancestorType, becauseAncestorOpened }`:
20821
- * - No <dialog>/<details>/[popover]/[aria-expanded] ancestor at all →
20822
- * `{ ancestor: document, ancestorType: "document" }`.
20823
- * - Otherwise → `{ ancestor: <the matched element>, ancestorType: "dialog"
20824
- * | "popover" | "details" | "aria-expanded" }`.
20825
- * `becauseAncestorOpened` distinguishes the two ways of coming on screen:
20826
- * - true — the element was already mounted and the ancestor just opened,
20827
- * revealing it along with everything else it holds. The opening has an
20828
- * owner (the ancestor's own transferFocus/openEffect), and what it reveals
20829
- * should defer to it — see use_auto_focus.js.
20830
- * - false — the element was mounted just now, into a surface already on
20831
- * screen (or into the plain document). Nothing else owns this appearance:
20832
- * what the element says about itself (an autofocus, a measurement) is the
20833
- * only word there is.
20834
- *
20835
- * Usage:
20836
- * useDisplayedLayoutEffect(ref, () => {
20837
- * scrollToSelected();
20838
- * }, []);
20839
- */
20840
- const useDisplayedLayoutEffect = (ref, callback, deps) => {
20841
- if (typeof callback !== "function") {
20842
- throw new TypeError("useDisplayedLayoutEffect: callback is not a function");
20843
- }
20844
-
20845
- // Keep a stable ref so the open listener always calls the latest callback
20846
- // without needing to be re-registered when deps change.
20847
- const callbackRef = useRef(callback);
20848
- callbackRef.current = callback;
20849
-
20850
- // Run on mount (or when deps change) — but only if the element is visible.
20851
- useLayoutEffect(() => {
20852
- const el = ref.current;
20853
- if (!el) {
20854
- return;
20855
- }
20856
- const ancestor = closestOpenableAncestor(el);
20857
- if (!ancestor) {
20858
- callbackRef.current(el, createDisplayedEvent(document, false));
20859
- return;
20860
- }
20861
- if (!isAncestorOpen(ancestor)) {
20862
- // Ancestor is closed — skip now; the observeAncestorOpenState call
20863
- // below will fire once it opens.
20864
- return;
20865
- }
20866
- callbackRef.current(el, createDisplayedEvent(ancestor, false));
20867
- }, deps);
20868
-
20869
- // Re-run every time the ancestor opens.
20870
- useLayoutEffect(() => {
20871
- const el = ref.current;
20872
- if (!el) {
20873
- return undefined;
20874
- }
20875
- const ancestor = closestOpenableAncestor(el);
20876
- if (!ancestor) {
20877
- return undefined;
20878
- }
20879
- return observeAncestorOpenState(ancestor, ({ isOpen }) => {
20880
- if (!isOpen) {
20881
- return;
20882
- }
20883
- const lastEl = ref.current;
20884
- callbackRef.current(lastEl, createDisplayedEvent(ancestor, true));
20885
- });
20886
- }, []);
20887
- };
20888
-
20889
- const createDisplayedEvent = (ancestor, becauseAncestorOpened) => {
20890
- return new CustomEvent("navi_displayed", {
20891
- detail: {
20892
- ancestor,
20893
- ancestorType: getAncestorOpenType(ancestor),
20894
- becauseAncestorOpened,
20895
- },
20896
- });
20897
- };
20898
-
20899
21642
  /**
20900
21643
  * Decides which element receives focus when a container (popover, dialog, …)
20901
21644
  * opens, and gives it back to where it came from when the container closes.
@@ -27435,13 +28178,11 @@ const useInteractiveProps = (props, {
27435
28178
  const [controlRootProps, controlHostProps] = splitControlProps(props);
27436
28179
  controlRootProps["navi-control"] = controlInfo.controlType;
27437
28180
  if (props.ownTarget) {
27438
- // "This press is mine" said in the DOM, because that is where it is read
27439
- // from the outside: by the controls above (see own_target.js), and by the
27440
- // two gesture readers below the one that travels a box and the one that
27441
- // carries a piece, each with its own way of being told to keep out.
27442
- controlRootProps[OWN_TARGET_ATTRIBUTE] = "";
27443
- controlRootProps["data-no-drag-travel"] = "";
27444
- controlRootProps["data-drag-ignore"] = "";
28181
+ // One attribute, in the DOM, because that is where the claim is read from —
28182
+ // by the controls above and by the gesture readers below (see
28183
+ // own_target.js). The prop is the ergonomic form of it and nothing more: an
28184
+ // element an application draws itself writes the same attribute by hand.
28185
+ controlRootProps[OWN_TARGET_ATTRIBUTE] = typeof props.ownTarget === "string" ? props.ownTarget : "";
27445
28186
  }
27446
28187
  const {
27447
28188
  "navi-control-proxy-for": naviProxyFor
@@ -27477,10 +28218,10 @@ const useInteractiveProps = (props, {
27477
28218
  });
27478
28219
  }
27479
28220
  {
27480
- const controlDisabled = useContext(DisabledContext);
27481
- const controlReadOnly = useContext(ReadOnlyContext);
28221
+ const controlDisabledFromAbove = useContext(DisabledContext);
28222
+ const controlReadOnlyFromAbove = useContext(ReadOnlyContext);
27482
28223
  const controlRequired = useContext(RequiredContext);
27483
- const controlLoading = useContext(LoadingContext$1);
28224
+ const controlLoadingFromAbove = useContext(LoadingContext$1);
27484
28225
  const parentActionRequester = useContext(ActionRequesterContext);
27485
28226
  const actionStatus = useActionStatus(boundAction);
27486
28227
  const {
@@ -27490,6 +28231,16 @@ const useInteractiveProps = (props, {
27490
28231
  loading,
27491
28232
  optimistic
27492
28233
  } = props;
28234
+
28235
+ // `ownTarget="always"`: an affordance that writes nothing to the control it
28236
+ // sits in has no business inheriting that control's state — a diskette
28237
+ // saving a row into the reader's own address book stays pressable on a game
28238
+ // nobody may edit. Its own props still hold; only what came from above is
28239
+ // dropped (see own_target.js for the three modes).
28240
+ const zoneStateApplies = !ownTargetIgnoresZoneState(props.ownTarget);
28241
+ const controlDisabled = zoneStateApplies && controlDisabledFromAbove;
28242
+ const controlReadOnly = zoneStateApplies && controlReadOnlyFromAbove;
28243
+ const controlLoading = zoneStateApplies && controlLoadingFromAbove;
27493
28244
  const disabledResolved = disabled || controlDisabled;
27494
28245
  const requiredResolved = required || controlRequired;
27495
28246
  // Busy because the group above is running the action THIS control asked
@@ -27501,7 +28252,7 @@ const useInteractiveProps = (props, {
27501
28252
  // (`maxLengthGuard`) and this one would make it longer: it can be pointed
27502
28253
  // at, focused and pressed — and answers why (see readonly_constraint.js) —
27503
28254
  // but cannot be taken.
27504
- const readOnlyFromParentMaxLengthGuard = Boolean(uiStateController.parentUIStateController?.isChildBlockedByMaxLengthGuard?.(uiStateController));
28255
+ const readOnlyFromParentMaxLengthGuard = Boolean(zoneStateApplies && uiStateController.parentUIStateController?.isChildBlockedByMaxLengthGuard?.(uiStateController));
27505
28256
  const readOnlyBase = readOnly || controlReadOnly || loadingBase || readOnlyFromParentMaxLengthGuard || controlInfo.readOnlyUncontrolled;
27506
28257
  // An optimistic control trusts its action to succeed: the state the user
27507
28258
  // just set stays visible and interactive while the action runs — no
@@ -27935,7 +28686,7 @@ const getAssociatedLabels = element => {
27935
28686
  return Array.from(element.labels);
27936
28687
  };
27937
28688
 
27938
- installImportMetaCssBuild(import.meta);const css$$ = /* css */`
28689
+ installImportMetaCssBuild(import.meta);const css$Z = /* css */`
27939
28690
  @layer navi {
27940
28691
  .navi_button {
27941
28692
  --button-border-radius: var(--navi-control-border-radius);
@@ -28366,7 +29117,7 @@ installImportMetaCssBuild(import.meta);const css$$ = /* css */`
28366
29117
  }
28367
29118
  `;
28368
29119
  const ButtonUI = props => {
28369
- import.meta.css = [css$$, "@jsenv/navi/src/control/input/button_ui.jsx"];
29120
+ import.meta.css = [css$Z, "@jsenv/navi/src/control/input/button_ui.jsx"];
28370
29121
  const {
28371
29122
  ref,
28372
29123
  // href/link
@@ -28379,6 +29130,7 @@ const ButtonUI = props => {
28379
29130
  icon,
28380
29131
  cta,
28381
29132
  spacing,
29133
+ emojiAsIcon = true,
28382
29134
  // Whether the button draws the loading outline itself. A button that is
28383
29135
  // one half of a bigger control says no: what is busy is the control, and
28384
29136
  // the outline belongs around the whole of it (see split_button.jsx).
@@ -28436,6 +29188,7 @@ const ButtonUI = props => {
28436
29188
 
28437
29189
  type: "button",
28438
29190
  spacing: undefined,
29191
+ emojiAsIcon: undefined,
28439
29192
  cta: undefined,
28440
29193
  pressEffect: undefined,
28441
29194
  loadingOutline: undefined,
@@ -28489,6 +29242,7 @@ const ButtonUI = props => {
28489
29242
  ...controlChildrenWrapperProps,
28490
29243
  children: jsx(ButtonContent, {
28491
29244
  spacing: spacing,
29245
+ emojiAsIcon: emojiAsIcon,
28492
29246
  children: children
28493
29247
  })
28494
29248
  })]
@@ -28496,6 +29250,7 @@ const ButtonUI = props => {
28496
29250
  };
28497
29251
  const ButtonContent = ({
28498
29252
  spacing,
29253
+ emojiAsIcon,
28499
29254
  children
28500
29255
  }) => {
28501
29256
  const boxForwardedProps = useContext(BoxForwardedPropsContext);
@@ -28503,6 +29258,7 @@ const ButtonContent = ({
28503
29258
  ...boxForwardedProps,
28504
29259
  display: "inherit",
28505
29260
  spacing: spacing,
29261
+ emojiAsIcon: emojiAsIcon,
28506
29262
  className: "navi_button_content",
28507
29263
  children: [children, jsx(ButtonShadow, {})]
28508
29264
  });
@@ -28665,17 +29421,22 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
28665
29421
 
28666
29422
  /**
28667
29423
  * @type {import("ignore:preact").FunctionComponent<{
28668
- * ownTarget?: boolean | "refuse",
29424
+ * ownTarget?: boolean | "refuse" | "always",
29425
+ * emojiAsIcon?: boolean,
28669
29426
  * [key: string]: any,
28670
29427
  * }>}
28671
- * @param {boolean|"refuse"} [ownTarget] A real target inside a zone that belongs
28672
- * to another control a chip's cross on a picker's façade, an eye on a
28673
- * pressable row, a diskette inside a slide that travels. The press is this
28674
- * button's alone (no travel starts, no popup opens, nothing above answers) and
28675
- * its `onClick` waits for its own interaction gate instead of firing from the
28676
- * DOM. Where the zone around it is read-only, disabled or busy the button
28677
- * goes; `"refuse"` keeps it on screen refusing with a callout, for an
28678
- * affordance whose presence is information in itself.
29428
+ * @param {boolean} [emojiAsIcon=true] Renders the emoji of the label as icons
29429
+ * so the button keeps the height of its text — `Text`'s prop, on by default
29430
+ * here. Pass `false` to let an emoji draw at its natural size.
29431
+ * @param {boolean|"refuse"|"always"} [ownTarget] A real target inside a zone
29432
+ * that belongs to another control a chip's cross on a picker's façade, an
29433
+ * eye on a pressable row, a diskette inside a slide that travels. The press is
29434
+ * this button's alone (no travel starts, no popup opens, no navi control above
29435
+ * answers) and its `onClick` waits for its own interaction gate instead of
29436
+ * firing from the DOM. What it does where the zone is read-only, disabled or
29437
+ * busy depends on whether it WRITES to the control it sits in: it goes by
29438
+ * default, `"refuse"` keeps it and refuses with a callout, `"always"` ignores
29439
+ * the zone's state entirely — for a gesture that never touched that control.
28679
29440
  */
28680
29441
  const Button = createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonUI]);
28681
29442
 
@@ -30096,7 +30857,7 @@ installImportMetaCssBuild(import.meta);/**
30096
30857
  * reaches the real container.
30097
30858
  */
30098
30859
  let openLocalDialogCount = 0;
30099
- const css$_ = /* css */`
30860
+ const css$Y = /* css */`
30100
30861
  @layer navi {
30101
30862
  .navi_dialog {
30102
30863
  /* Min gap between the dialog and the edges of its container. Written
@@ -30684,7 +31445,7 @@ const css$_ = /* css */`
30684
31445
  * @param {import("ignore:preact").ComponentChildren} props.children
30685
31446
  */
30686
31447
  const Dialog = props => {
30687
- import.meta.css = [css$_, "@jsenv/navi/src/layout/dialog.jsx"];
31448
+ import.meta.css = [css$Y, "@jsenv/navi/src/layout/dialog.jsx"];
30688
31449
  if (props.openController) {
30689
31450
  return jsx(ControlledDialog, {
30690
31451
  ...props
@@ -31676,7 +32437,7 @@ installImportMetaCssBuild(import.meta);/**
31676
32437
  * and applied.
31677
32438
  */
31678
32439
  let openLocalPopoverCount = 0;
31679
- const css$Z = /* css */`
32440
+ const css$X = /* css */`
31680
32441
  @layer navi {
31681
32442
  .navi_popover {
31682
32443
  /* soft: user-configurable preferred max-height. Kept as a *default*
@@ -32136,7 +32897,7 @@ const css$Z = /* css */`
32136
32897
  * @param {import("ignore:preact").ComponentChildren} props.children
32137
32898
  */
32138
32899
  const Popover = props => {
32139
- import.meta.css = [css$Z, "@jsenv/navi/src/layout/popover.jsx"];
32900
+ import.meta.css = [css$X, "@jsenv/navi/src/layout/popover.jsx"];
32140
32901
  if (props.openController) {
32141
32902
  return jsx(ControlledPopover, {
32142
32903
  ...props
@@ -33164,7 +33925,7 @@ installImportMetaCssBuild(import.meta);/**
33164
33925
  * event, and a caller replacing the body entirely then has one protocol to
33165
33926
  * follow — `--navi-confirm` for yes, anything that closes for no.
33166
33927
  */
33167
- const css$Y = /* css */`
33928
+ const css$W = /* css */`
33168
33929
  /* The width lives on the body rather than on the popup, so that custom
33169
33930
  content (which replaces this body entirely) sizes itself instead of
33170
33931
  inheriting a ceiling meant for a sentence-long question. */
@@ -33301,7 +34062,7 @@ const ConfirmPopup = ({
33301
34062
  onAnswer,
33302
34063
  onClosed
33303
34064
  }) => {
33304
- import.meta.css = [css$Y, "@jsenv/navi/src/action/confirm_popup.jsx"];
34065
+ import.meta.css = [css$W, "@jsenv/navi/src/action/confirm_popup.jsx"];
33305
34066
  const {
33306
34067
  mode,
33307
34068
  confirmLabel,
@@ -33385,7 +34146,7 @@ const defaultBody = (message, {
33385
34146
  });
33386
34147
  };
33387
34148
 
33388
- installImportMetaCssBuild(import.meta);const css$X = /* css */`
34149
+ installImportMetaCssBuild(import.meta);const css$V = /* css */`
33389
34150
  .action_error {
33390
34151
  margin-top: 0;
33391
34152
  margin-bottom: 20px;
@@ -33410,7 +34171,7 @@ const ActionRenderer = ({
33410
34171
  children,
33411
34172
  disabled
33412
34173
  }) => {
33413
- import.meta.css = [css$X, "@jsenv/navi/src/action/action_renderer.jsx"];
34174
+ import.meta.css = [css$V, "@jsenv/navi/src/action/action_renderer.jsx"];
33414
34175
  if (action === undefined) {
33415
34176
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
33416
34177
  }
@@ -39035,7 +39796,7 @@ const ROUTE_TRAVEL_ATTRIBUTE = "data-navi-route-travel";
39035
39796
  // the root pictures must NOT move (they carry the whole viewport, blank bands
39036
39797
  // included).
39037
39798
 
39038
- const css$W = /* css */`
39799
+ const css$U = /* css */`
39039
39800
  /* The marked region is a picture of its own for the length of a transition of
39040
39801
  OURS, and only then — the name is what makes the pages a picture the
39041
39802
  movement below can carry.
@@ -39464,7 +40225,7 @@ const RouteTransitionArea = ({
39464
40225
  children,
39465
40226
  ...rest
39466
40227
  }) => {
39467
- import.meta.css = [css$W, "@jsenv/navi/src/nav/route_transition.jsx"];
40228
+ import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
39468
40229
  const props = {
39469
40230
  ...rest,
39470
40231
  [TRANSITION_AREA_ATTRIBUTE]: ""
@@ -39521,7 +40282,7 @@ const RouteTransitionArea = ({
39521
40282
  * @returns {() => void} remove this relation.
39522
40283
  */
39523
40284
  const defineRouteTransition = (from, to, transition) => {
39524
- import.meta.css = [css$W, "@jsenv/navi/src/nav/route_transition.jsx"];
40285
+ import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
39525
40286
  const {
39526
40287
  type,
39527
40288
  duration
@@ -39557,7 +40318,7 @@ const defineRouteTransition = (from, to, transition) => {
39557
40318
  * @returns {() => void} remove this default.
39558
40319
  */
39559
40320
  const defineRouteDefaultTransition = transition => {
39560
- import.meta.css = [css$W, "@jsenv/navi/src/nav/route_transition.jsx"];
40321
+ import.meta.css = [css$U, "@jsenv/navi/src/nav/route_transition.jsx"];
39561
40322
  const value = normalizeTransition(transition);
39562
40323
  defaultTransition = value;
39563
40324
  return () => {
@@ -40158,7 +40919,7 @@ const DRAGGED_ATTRIBUTE = "data-navi-route-travel-dragged";
40158
40919
  const TURNED_ATTRIBUTE = "data-navi-route-travel-turned";
40159
40920
  // The name the box wears while it travels, and only then (see nameForTravel).
40160
40921
  const TRAVEL_NAME = "navi-route-travel";
40161
- const css$V = /* css */`
40922
+ const css$T = /* css */`
40162
40923
  /* The name that makes the page inside this box a picture of its own during a
40163
40924
  transition — rather than part of the one big picture the document takes, so
40164
40925
  the two pages can move past each other while everything else stays where it
@@ -40548,7 +41309,7 @@ const RouteTravel = ({
40548
41309
  children,
40549
41310
  ...rest
40550
41311
  }) => {
40551
- import.meta.css = [css$V, "@jsenv/navi/src/nav/route_travel.jsx"];
41312
+ import.meta.css = [css$T, "@jsenv/navi/src/nav/route_travel.jsx"];
40552
41313
  const elementRef = useRef();
40553
41314
  const gestureRef = useRef(null);
40554
41315
  // The travel in hand: the transition keeping the picture of the page being
@@ -43109,402 +43870,6 @@ const PhoneSvg = () => {
43109
43870
  });
43110
43871
  };
43111
43872
 
43112
- installImportMetaCssBuild(import.meta);// # TextAnchor — how it works
43113
- const css$U = /* css */`
43114
- .navi_text_anchor {
43115
- vertical-align: baseline;
43116
- user-select: none;
43117
- overflow: hidden;
43118
- }
43119
- `;
43120
-
43121
- /**
43122
- * Positions children vertically relative to the surrounding text, correcting for font-size differences.
43123
- *
43124
- * Place this component around any inline element whose font-size differs from the surrounding text.
43125
- * It renders an invisible anchor that inherits the surrounding text's font metrics, then shifts
43126
- * the child so that its visual position matches the requested `textAnchor` value — regardless of
43127
- * font-size, display type (inline, inline-block, inline-flex…), or the active `vertical-align`.
43128
- *
43129
- * @param {"line-top"|"char-top"|"center"|"char-bottom"|"line-bottom"} [textAnchor="char-bottom"]
43130
- * - `"line-top"` — child top aligns with the top of the surrounding line box
43131
- * - `"char-top"` — child top aligns with the top of visible characters (ink ascent)
43132
- * - `"center"` — child is vertically centered on the surrounding line box
43133
- * - `"char-bottom"` — child bottom aligns to the text baseline (no correction, browser default)
43134
- * - `"line-bottom"` — child bottom aligns with the bottom of the surrounding line box
43135
- * @param {{ size?: number, verticalAlign?: string }} [lineLayout]
43136
- * Describes the surrounding line context. Used as layout-effect dependencies so the correction
43137
- * reruns when the surrounding text's font-size or vertical-align changes.
43138
- * @param {import("ignore:preact").RefObject} childRef — ref on the child element to reposition
43139
- */
43140
- const TextAnchor = ({
43141
- childRef,
43142
- children,
43143
- textAnchor = "char-bottom",
43144
- textKey,
43145
- textSize,
43146
- lineLayout
43147
- }) => {
43148
- import.meta.css = [css$U, "@jsenv/navi/src/text/text_anchor.jsx"];
43149
- const anchorRef = useRef();
43150
-
43151
- // Plain useLayoutEffect would also fire while an ancestor dialog/popover
43152
- // (e.g. a closed SidePanel) is still display:none — every rect involved
43153
- // reads 0×0 at that point, so the math trivially (and wrongly) resolves
43154
- // to topOffset 0: not a real "no correction needed" result, just a
43155
- // zero-by-zero coincidence that happens to look fine only because it
43156
- // leaves the browser's own default alignment untouched. The real
43157
- // correction then only gets applied later, on whatever unrelated
43158
- // re-render next happens to change one of this effect's own deps —
43159
- // which reads as the child "jumping" even though nothing about its own
43160
- // geometry changed. useDisplayedLayoutEffect skips the initial run in
43161
- // that case and reruns once the ancestor actually opens instead.
43162
- useDisplayedLayoutEffect(anchorRef, anchorEl => {
43163
- const childEl = childRef.current;
43164
- if (!anchorEl || !childEl) {
43165
- return;
43166
- }
43167
- // Only correct when the anchor lives in an inline formatting context.
43168
- // If the parent is a flex/grid container, inline layout rules don't apply
43169
- // and our font-metrics model is invalid.
43170
- const parentDisplay = getComputedStyle(anchorEl.parentElement).display;
43171
- if (parentDisplay !== "inline" && parentDisplay !== "inline-block" && parentDisplay !== "block") {
43172
- // we must hide the anchor otherwise it would affect layout without providing any benefit (would trigger flex gap for instance)
43173
- anchorEl.setAttribute("hidden", "");
43174
- setTopOffset(childEl, 0);
43175
- return;
43176
- }
43177
- anchorEl.removeAttribute("hidden");
43178
- const topOffset = computeTopOffset({
43179
- anchorEl,
43180
- childEl,
43181
- textAnchor
43182
- });
43183
- setTopOffset(childEl, topOffset);
43184
- }, [textAnchor, textKey, textSize, lineLayout?.size, lineLayout?.verticalAlign]);
43185
- return jsxs(Fragment$1, {
43186
- children: [children, jsx("span", {
43187
- ref: anchorRef,
43188
- className: "navi_text_anchor",
43189
- "aria-hidden": "true",
43190
- children: "\u200B"
43191
- })]
43192
- });
43193
- };
43194
- const setTopOffset = (childEl, topOffset) => {
43195
- // position:relative + top shifts the element visually.
43196
- // marginTop: -topOffset makes the layout box follow the visual position, so any container
43197
- // (button, link, box…) computes its own padding/border/height based on the real final position
43198
- // rather than the original unshifted one. This means a badge inside a button will symmetrically
43199
- // expand the button height instead of overflowing or being clipped.
43200
- // marginBottom: topOffset compensates the marginTop so the line height stays unchanged —
43201
- // the shift is purely a repositioning, not an inflation of the line.
43202
- if (!topOffset) {
43203
- childEl.style.position = "";
43204
- childEl.style.top = "";
43205
- childEl.style.marginTop = "";
43206
- childEl.style.marginBottom = "";
43207
- return;
43208
- }
43209
- childEl.style.position = "relative";
43210
- childEl.style.top = `${topOffset}px`;
43211
- childEl.style.marginTop = `${-topOffset}px`;
43212
- childEl.style.marginBottom = `${topOffset}px`;
43213
- };
43214
- const computeTopOffset = ({
43215
- anchorEl,
43216
- childEl,
43217
- textAnchor
43218
- }) => {
43219
- if (textAnchor === "char-bottom") {
43220
- // Align child's bottom with the char's bottom = the baseline.
43221
- // The CSS spec says an inline-block with no text content has its baseline at its bottom margin edge.
43222
- // So the browser's default placement already puts the child's bottom at the line's baseline.
43223
- // No correction needed.
43224
- return 0;
43225
- }
43226
- // The anchor's rendered rect corresponds to the surrounding text's line box:
43227
- // top and bottom are the visual bounds of the line (including line-height).
43228
- const anchorRect = anchorEl.getBoundingClientRect();
43229
-
43230
- // Measure the child's current rect, then subtract any previously applied top correction
43231
- // to recover its natural position — avoiding a style reset + reflow.
43232
- const childRect = childEl.getBoundingClientRect();
43233
- const childH = childRect.height;
43234
- const previousTop = parseFloat(childEl.style.top) || 0;
43235
- const childNaturalTop = childRect.top - previousTop;
43236
-
43237
- // Compute desired child top Y based on textAnchor intention.
43238
- let desiredChildTopY = 0;
43239
- if (textAnchor === "line-top") {
43240
- desiredChildTopY = anchorRect.top;
43241
- } else if (textAnchor === "char-top") {
43242
- const anchorStyle = getComputedStyle(anchorEl);
43243
- const ctx = charTopCanvas.getContext("2d");
43244
- ctx.font = `${anchorStyle.fontWeight} ${anchorStyle.fontSize} ${anchorStyle.fontFamily}`;
43245
- const m = ctx.measureText("M");
43246
- const baselineY = anchorRect.bottom - m.fontBoundingBoxDescent;
43247
- desiredChildTopY = baselineY - m.actualBoundingBoxAscent;
43248
- } else if (textAnchor === "center") {
43249
- const anchorCenterY = (anchorRect.top + anchorRect.bottom) / 2;
43250
- desiredChildTopY = anchorCenterY - childH / 2;
43251
- } else if (textAnchor === "char-bottom") {
43252
- // Already handled above (early return 0), but guard here for completeness.
43253
- return 0;
43254
- } else if (textAnchor === "line-bottom") {
43255
- desiredChildTopY = anchorRect.bottom - childH;
43256
- } else {
43257
- return 0; // unknown textAnchor, no correction
43258
- }
43259
- return desiredChildTopY - childNaturalTop;
43260
- };
43261
- const charTopCanvas = document.createElement("canvas");
43262
-
43263
- installImportMetaCssBuild(import.meta);const css$T = /* css */`
43264
- @layer navi {
43265
- /* Ensure data attributes from box.jsx can win to update display */
43266
- .navi_icon {
43267
- display: inline-flex;
43268
- box-sizing: border-box;
43269
- max-width: 100%;
43270
- /* An icon never grows past the box it sits in, so a glyph can never make
43271
- a line of text taller than the text itself. lineOverflow="allow" opts
43272
- out, for an icon that is an affordance rather than a character — a
43273
- control's chevron or clear button, sized to be touched, not read. */
43274
- max-height: 100%;
43275
-
43276
- &[data-line-overflow="allow"] {
43277
- max-height: none;
43278
- }
43279
- }
43280
- }
43281
-
43282
- .navi_icon {
43283
- white-space: nowrap;
43284
- vertical-align: inherit;
43285
-
43286
- &[data-icon-char] {
43287
- aspect-ratio: 1/1;
43288
- min-width: 0;
43289
- height: round(1em, 1px);
43290
- max-height: round(1em, 1px);
43291
- flex-grow: 0 !important;
43292
- align-items: center;
43293
- justify-content: center;
43294
-
43295
- /* fillLine: measured on the line box (1lh) instead of the character box
43296
- (1em). The icon still stays inside the line — it just uses all of it,
43297
- which is what an icon standing on its own in a control's slot wants,
43298
- where a glyph sitting among letters wants to match their size. */
43299
- &[data-fill-line] {
43300
- height: round(1lh, 1px);
43301
- max-height: round(1lh, 1px);
43302
- }
43303
-
43304
- svg,
43305
- img {
43306
- width: 100%;
43307
- height: 100%;
43308
- }
43309
- svg {
43310
- overflow: visible;
43311
- }
43312
- }
43313
- &[data-flow-inline] {
43314
- width: 1em;
43315
- height: 1em;
43316
- }
43317
- &[data-interactive] {
43318
- cursor: pointer;
43319
- }
43320
- &[data-icon-text] {
43321
- -webkit-font-smoothing: antialiased;
43322
- text-rendering: optimizeLegibility;
43323
- }
43324
- }
43325
-
43326
- .navi_icon > svg,
43327
- .navi_icon > img {
43328
- width: 100%;
43329
- height: 100%;
43330
- }
43331
- .navi_icon[data-width-fixed] > svg,
43332
- .navi_icon[data-width-fixed] > img {
43333
- width: 100%;
43334
- height: auto;
43335
- }
43336
- .navi_icon[data-height-fixed] > svg,
43337
- .navi_icon[data-height-fixed] > img {
43338
- width: auto;
43339
- height: 100%;
43340
- }
43341
- .navi_icon[data-width-fixed][data-height-fixed] > svg,
43342
- .navi_icon[data-width-fixed][data-height-fixed] > img {
43343
- width: 100%;
43344
- height: 100%;
43345
- }
43346
- `;
43347
-
43348
- /**
43349
- * Renders an icon — an inline SVG/emoji/text glyph that inherits the
43350
- * surrounding text's `currentColor` and (by default) its font size, so it sits
43351
- * on the text baseline like a character.
43352
- *
43353
- * Content comes from either `href` (references an external/sprite symbol via
43354
- * `<use>`) or `children` (an inline `<svg>` element, or a string for a
43355
- * text/emoji icon). All extra props are spread onto the underlying `Box`/`Text`
43356
- * (sizing, spacing, color, className, data-attributes, …).
43357
- *
43358
- * Render mode is chosen automatically:
43359
- * - `children` is a **string** → a text icon (`<Text data-icon-text>`).
43360
- * - **sized** (an explicit `width`/`height`, or `flex`/`grid`) → a block icon
43361
- * (`<Box square>`), laid out as its own box rather than inline.
43362
- * - otherwise → an **inline char-like** icon that flows on the text baseline
43363
- * (`data-icon-char`), aligned via `textAnchor`.
43364
- *
43365
- * Accessibility: an icon is treated as decorative (`aria-hidden`) by default
43366
- * whenever it has no explicit size and no `onClick`; give it an explicit
43367
- * `decorative={false}` (or make it interactive) when it conveys meaning.
43368
- *
43369
- * @param {object} props
43370
- * @param {string} [props.href] - URL/id of an external SVG symbol, rendered via
43371
- * `<svg><use href></svg>`. Mutually exclusive with meaningful `children`.
43372
- * @param {import("ignore:preact").ComponentChildren} [props.children] - Inline icon
43373
- * content: an `<svg>` element, or a string (renders as a text/emoji icon).
43374
- * @param {boolean} [props.decorative] - Marks the icon `aria-hidden`. Defaults
43375
- * to `true` for an unsized, non-interactive icon; pass `false` for a
43376
- * meaning-bearing icon that needs to be exposed to assistive tech.
43377
- * @param {(event: MouseEvent) => void} [props.onClick] - Makes the icon
43378
- * interactive (`data-interactive`, pointer cursor) and non-decorative.
43379
- * @param {"line-top"|"char-top"|"center"|"char-bottom"|"line-bottom"} [props.textAnchor="center"]
43380
- * - Vertical alignment within the surrounding text line for the inline
43381
- * char-like mode, forwarded to `TextAnchor`: `"line-top"`/`"line-bottom"`
43382
- * align to the line box edges, `"char-top"` to the ink ascent, `"center"`
43383
- * centers on the line box, `"char-bottom"` sits on the baseline. See
43384
- * `text_anchor.jsx`.
43385
- * @param {{ size?: number, verticalAlign?: string }} [props.lineLayout] -
43386
- * Describes the surrounding line context (font size / vertical-align),
43387
- * forwarded to `TextAnchor` so it recomputes the vertical correction when
43388
- * that context changes.
43389
- * @param {string|number} [props.width] - Explicit width; `"auto"` clears it.
43390
- * Any explicit size switches the icon to block (sized) mode.
43391
- * @param {string|number} [props.height] - Explicit height; `"auto"` clears it.
43392
- * @param {"allow"} [props.lineOverflow] - `"allow"` lets the icon be taller
43393
- * than the box it sits in (a line of text, a control's slot) instead of being
43394
- * capped by it. For an icon that is an affordance sized for the finger rather
43395
- * than a character sized for reading.
43396
- * @param {boolean} [props.fillLine] - Sizes the icon on the line box (1lh)
43397
- * rather than on the character box (1em), so it uses the full height of the
43398
- * line without leaving it. Unlike `lineOverflow`, the icon still never
43399
- * exceeds the line.
43400
- * @param {boolean} [props.square] - Keep a 1:1 box; combined with one explicit
43401
- * dimension it fixes the other too.
43402
- * @param {boolean} [props.circle] - Like `square`, plus a circular shape.
43403
- * @param {string|number} [props.aspectRatio] - Fixes the second dimension from
43404
- * the one explicit dimension.
43405
- * @param {"x"|"y"|boolean} [props.flex] - Forces block/flex layout; auto-set to
43406
- * `"x"` when the icon is sized.
43407
- * @param {boolean} [props.grid] - Forces block/grid layout.
43408
- * @param {string} [props.className] - Merged with the base `"navi_icon"` class.
43409
- */
43410
- const Icon = ({
43411
- href,
43412
- children,
43413
- decorative,
43414
- onClick,
43415
- textAnchor = "center",
43416
- lineLayout,
43417
- lineOverflow,
43418
- fillLine,
43419
- ...props
43420
- }) => {
43421
- import.meta.css = [css$T, "@jsenv/navi/src/text/icon.jsx"];
43422
- const innerChildren = href ? jsx("svg", {
43423
- width: "100%",
43424
- height: "100%",
43425
- children: jsx("use", {
43426
- href: href
43427
- })
43428
- }) : children;
43429
- let {
43430
- flex,
43431
- grid,
43432
- width,
43433
- height
43434
- } = props;
43435
- if (width === "auto") {
43436
- width = undefined;
43437
- }
43438
- if (height === "auto") {
43439
- height = undefined;
43440
- }
43441
- const hasExplicitWidth = width !== undefined;
43442
- const hasExplicitHeight = height !== undefined;
43443
- const widthFixed = hasExplicitWidth || hasExplicitHeight && (props.square || props.circle || props.aspectRatio);
43444
- const heightFixed = hasExplicitHeight || hasExplicitWidth && (props.square || props.circle || props.aspectRatio);
43445
- if (widthFixed || heightFixed) {
43446
- if (flex === undefined) {
43447
- flex = "x";
43448
- }
43449
- } else if (decorative === undefined && !onClick) {
43450
- decorative = true;
43451
- }
43452
- const ariaProps = decorative ? {
43453
- "aria-hidden": "true"
43454
- } : {};
43455
- const textRef = useRef();
43456
- if (typeof children === "string") {
43457
- return jsx(Text, {
43458
- ...props,
43459
- ...ariaProps,
43460
- "data-icon-text": "",
43461
- "data-line-overflow": lineOverflow,
43462
- "data-fill-line": fillLine ? "" : undefined,
43463
- children: children
43464
- });
43465
- }
43466
- if (flex || grid) {
43467
- return jsx(Box, {
43468
- square: true,
43469
- ...props,
43470
- ...ariaProps,
43471
- flex: flex,
43472
- baseClassName: "navi_icon",
43473
- "data-width-fixed": widthFixed ? "" : undefined,
43474
- "data-height-fixed": heightFixed ? "" : undefined,
43475
- "data-interactive": onClick ? "" : undefined,
43476
- "data-line-overflow": lineOverflow,
43477
- "data-fill-line": fillLine ? "" : undefined,
43478
- onClick: onClick,
43479
- children: innerChildren
43480
- });
43481
- }
43482
- return jsx(TextAnchor, {
43483
- childRef: textRef,
43484
- textAnchor: textAnchor,
43485
- textSize: props.size,
43486
- lineLayout: lineLayout,
43487
- children: jsxs(Text, {
43488
- ...props,
43489
- ...ariaProps,
43490
- className: withPropsClassName("navi_icon", props.className),
43491
- spacing: "pre",
43492
- "data-icon-char": "",
43493
- "data-line-overflow": lineOverflow,
43494
- "data-fill-line": fillLine ? "" : undefined,
43495
- "data-width-fixed": widthFixed ? "" : undefined,
43496
- "data-height-fixed": heightFixed ? "" : undefined,
43497
- "data-interactive": onClick ? "" : undefined,
43498
- onClick: onClick,
43499
- ref: textRef,
43500
- children: [jsx("span", {
43501
- style: "user-select:none",
43502
- children: "\u200B"
43503
- }), innerChildren]
43504
- })
43505
- });
43506
- };
43507
-
43508
43873
  /**
43509
43874
  * Hook that reactively checks if a URL is visited.
43510
43875
  * Re-renders when the visited URL set changes.
@@ -63066,7 +63431,8 @@ const useBadgeRegistry = (children, enabled) => {
63066
63431
  * not the same in each. Nothing is set up for a case that cannot happen: a
63067
63432
  * plain list is one element holding its children as-is — no registry, no
63068
63433
  * effect —, a capped one collects its badges but measures nothing, and only
63069
- * shrinkWrap ever builds the measurement ghost.
63434
+ * shrinkWrap on its own builds the measurement ghost: under maxLines the list
63435
+ * is measured in place, once the rows are known.
63070
63436
  *
63071
63437
  * @param {import("ignore:preact").ComponentChildren} [fallback]
63072
63438
  * Rendered in place of the badges when there is none. Without it an empty
@@ -63081,8 +63447,9 @@ const useBadgeRegistry = (children, enabled) => {
63081
63447
  * Narrows the list down to its widest row so the last row isn't ragged.
63082
63448
  * Defaults to true inside a <Picker> — the trigger draws a border around the
63083
63449
  * list, so the ragged edge shows — and false elsewhere, where the work would
63084
- * often go unseen: opt in where an edge is visible. Ignored when maxLines is
63085
- * in play, which needs the full width to know where the rows fall.
63450
+ * often go unseen: opt in where an edge is visible. Composes with maxLines:
63451
+ * the rows are read at the full width first, and the list is narrowed once
63452
+ * the surplus is gone.
63086
63453
  * @param {number} [max]
63087
63454
  * Caps how many badges are rendered; the surplus becomes a "+N" badge, which
63088
63455
  * takes one of the max slots.
@@ -63106,11 +63473,10 @@ const BadgeList = props => {
63106
63473
  shrinkWrap = maxLinesFromAbove !== undefined
63107
63474
  } = props;
63108
63475
  if (maxLinesResolved !== undefined) {
63109
- // shrinkWrap is dropped on purpose: it narrows the list down to its widest
63110
- // row, which would re-wrap the badges under the cap just measured.
63111
63476
  return jsx(BadgeListMaxLines, {
63112
63477
  ...props,
63113
- maxLines: maxLinesResolved
63478
+ maxLines: maxLinesResolved,
63479
+ shrinkWrap: shrinkWrap
63114
63480
  });
63115
63481
  }
63116
63482
  if (shrinkWrap) {
@@ -63251,6 +63617,7 @@ const BadgeListMaxLines = ({
63251
63617
  children,
63252
63618
  max,
63253
63619
  maxLines,
63620
+ shrinkWrap,
63254
63621
  ...boxProps
63255
63622
  }) => {
63256
63623
  const registry = useBadgeRegistry(children, true);
@@ -63272,8 +63639,29 @@ const BadgeListMaxLines = ({
63272
63639
  // out of the cap and nothing has to be watched.
63273
63640
  const watchesResize = fit !== null && fit.count > 1;
63274
63641
 
63642
+ // The list's own width. While the rows are being read the list takes all the
63643
+ // room it is given — a width kept from an earlier shrink wrap would fold the
63644
+ // badges under it. Once the surplus is gone, shrinkWrap narrows the list to
63645
+ // its widest row, measured where the badges stand: nothing is painted before
63646
+ // the effect below has run, so no clone is needed.
63647
+ useLayoutEffect(() => {
63648
+ const visibleEl = visibleRef.current;
63649
+ if (!visibleEl) {
63650
+ return;
63651
+ }
63652
+ visibleEl.style.width = "";
63653
+ if (fit === null || !shrinkWrap) {
63654
+ return;
63655
+ }
63656
+ const widestRowWidth = measureWidestChildRow(visibleEl);
63657
+ if (widestRowWidth !== null) {
63658
+ visibleEl.style.width = `${Math.ceil(widestRowWidth)}px`;
63659
+ }
63660
+ }, [fit, shrinkWrap]);
63661
+
63275
63662
  // Runs after every render, which is when the badges have registered and the
63276
- // DOM holds whatever this render asked for.
63663
+ // DOM holds whatever this render asked for, at the width the effect above
63664
+ // gave it.
63277
63665
  useLayoutEffect(() => {
63278
63666
  const visibleEl = visibleRef.current;
63279
63667
  if (!visibleEl) {
@@ -63308,7 +63696,8 @@ const BadgeListMaxLines = ({
63308
63696
  // And not every width change either. Nothing guarantees an ancestor whose
63309
63697
  // width does not follow its content (a column with align-items: start
63310
63698
  // sizes every row to what is inside it), so rendering every badge widens
63311
- // what is being watched and dropping the surplus narrows it right back.
63699
+ // what is being watched and dropping the surplus then shrink wrapping
63700
+ // what is left — narrows it right back.
63312
63701
  // Those two widths are this list talking to itself; measuring again on
63313
63702
  // them never ends. Any other width is the room around it changing.
63314
63703
  const width = outerParent.getBoundingClientRect().width;
@@ -64177,7 +64566,7 @@ installImportMetaCssBuild(import.meta);const css$u = /* css */`
64177
64566
  means "open the picker". An own target is the exception, the same way
64178
64567
  the clear cross is one in the slot below: it says the press is aimed at
64179
64568
  IT, so it has to be reachable at all. */
64180
- [data-navi-own-target] {
64569
+ [data-own-target] {
64181
64570
  pointer-events: auto;
64182
64571
  }
64183
64572
  }
@@ -75686,6 +76075,9 @@ const MessageBox = ({
75686
76075
  padding = "sm",
75687
76076
  icon,
75688
76077
  leftStripe,
76078
+ // A message is free text: an emoji is expected in it, and must not push
76079
+ // the first line down next to the icon and the close button.
76080
+ emojiAsIcon = true,
75689
76081
  children,
75690
76082
  onClose,
75691
76083
  ...rest
@@ -75723,26 +76115,39 @@ const MessageBox = ({
75723
76115
  value: setHasTitleChild,
75724
76116
  children: [icon && jsx(Icon, {
75725
76117
  color: "var(--x-message-color)",
75726
- height: "1.5em",
76118
+ height: "1lh",
75727
76119
  maxHeight: "auto",
75728
76120
  selfAlignY: "start",
75729
76121
  aspectRatio: "auto",
75730
76122
  children: icon
75731
76123
  }), jsx(Text, {
76124
+ emojiAsIcon: emojiAsIcon,
75732
76125
  children: children
75733
- }), onClose && jsx(Button, {
75734
- action: onClose,
75735
- icon: true,
75736
- border: "none",
75737
- alignX: "center",
76126
+ }), onClose &&
76127
+ // A column as tall as the first line of the message, pinned to the
76128
+ // top, the button centered in it: the close button stays level
76129
+ // with the first line however many lines the message takes (same
76130
+ // layout as Callout).
76131
+ jsx(Box, {
76132
+ flex: true,
75738
76133
  alignY: "center",
75739
- style: {
75740
- ":hover": {
75741
- backgroundColor: "rgba(0, 0, 0, 0.1)"
75742
- }
75743
- },
75744
- children: jsx(Icon, {
75745
- children: jsx(CloseSvg, {})
76134
+ height: "1lh",
76135
+ selfAlignY: "start",
76136
+ shrink: false,
76137
+ children: jsx(Button, {
76138
+ action: onClose,
76139
+ icon: true,
76140
+ border: "none",
76141
+ alignX: "center",
76142
+ alignY: "center",
76143
+ style: {
76144
+ ":hover": {
76145
+ backgroundColor: "rgba(0, 0, 0, 0.1)"
76146
+ }
76147
+ },
76148
+ children: jsx(Icon, {
76149
+ children: jsx(CloseSvg, {})
76150
+ })
75746
76151
  })
75747
76152
  })]
75748
76153
  })
@@ -77830,5 +78235,5 @@ const UserSvg = () => jsx("svg", {
77830
78235
  })
77831
78236
  });
77832
78237
 
77833
- 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, Expandable, 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, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCanNavBack, useCanNavForward, 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, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
78238
+ 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, Expandable, 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, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineNaviConfirmPopupOptions, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, renderEmojiAsIcon, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCanNavBack, useCanNavForward, 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, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
77834
78239
  //# sourceMappingURL=jsenv_navi.js.map