@jsenv/navi 0.27.76 → 0.27.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { isValidElement, createContext, h, toChildArray, render, Fragment, clone
3
3
  import { useErrorBoundary, useLayoutEffect, useEffect, useContext, useMemo, useRef, useState, useCallback, useId } from "preact/hooks";
4
4
  import { jsxs, jsx, Fragment as Fragment$1 } from "preact/jsx-runtime";
5
5
  import { signal, effect, computed, batch, useSignal } from "@preact/signals";
6
- import { createIterableWeakSet, createEventGroupLogger, normalizeStyle, mergeOneStyle, createPubSub, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, dispatchCustomEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, getKeyboardEventDefaultAction, chainEvent, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, activeElementSignal, initFocusGroup, elementIsFocusable, parsePositionArea, getPositionedParent, snapToPixel, trapFocusInside, trapScrollInside, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
6
+ import { createIterableWeakSet, createEventGroupLogger, normalizeStyle, mergeOneStyle, createPubSub, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, dispatchCustomEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, resolveOklchLightness, contrastColor, activeElementSignal, initFocusGroup, elementIsFocusable, parsePositionArea, getPositionedParent, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom";
7
7
  export { contrastColor, startDragToReorder } from "@jsenv/dom";
8
8
  import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize";
9
9
  import { createValidity, parseDuration, durationContainsNaN, compareTwoDurations, durationToSeconds, durationToISOString } from "@jsenv/validity";
@@ -15965,8 +15965,7 @@ const renderIntoCallout = (jsx$1, calloutMessageElement, {
15965
15965
  render(calloutJsx, calloutMessageElement);
15966
15966
  };
15967
15967
 
15968
- installImportMetaCssBuild(import.meta);
15969
- /**
15968
+ installImportMetaCssBuild(import.meta);/**
15970
15969
  * A callout component that mimics native browser validation messages.
15971
15970
  * Features:
15972
15971
  * - Positions above or below target element based on available space
@@ -15975,7 +15974,6 @@ installImportMetaCssBuild(import.meta);
15975
15974
  * - Arrow automatically shows when pointing at a valid anchor element
15976
15975
  * - Centers in viewport when no anchor element provided or anchor is too big
15977
15976
  */
15978
-
15979
15977
  const css$Q = /* css */`
15980
15978
  @layer navi {
15981
15979
  .navi_callout {
@@ -15995,17 +15993,21 @@ const css$Q = /* css */`
15995
15993
  --x-callout-border-color: var(--x-callout-status-color);
15996
15994
  --x-callout-background-color: var(--callout-background-color);
15997
15995
  --x-callout-icon-color: var(--x-callout-status-color);
15998
-
15996
+ /* Default: no anchor at all (docked/centered in the viewport) — fixed
15997
+ is the direct way to stay pinned there. Overridden to absolute below
15998
+ only when data-anchor-scrolls is set (see its own JS-side comment,
15999
+ right where it's set) — same reasoning as Popover's identical
16000
+ attribute (popover.jsx's own top comment has the full case). */
16001
+ position: fixed;
15999
16002
  /* Popover resets */
16000
- position: absolute;
16001
16003
  inset: auto;
16002
16004
  top: 0;
16003
16005
  left: 0;
16004
16006
  /* Callout styles */
16005
16007
  display: block;
16006
- height: auto;
16008
+ height: auto; /* User agent reset */
16007
16009
  margin: 0;
16008
- padding: 0;
16010
+ padding: 0; /* User agent reset */
16009
16011
  color: revert; /* Do no inherit element color, callout is inside the element it should use document color though */
16010
16012
  font-size: initial; /* Callout fells disconnected from the element, font size should be predictible and stable */
16011
16013
  background: transparent;
@@ -16013,18 +16015,18 @@ const css$Q = /* css */`
16013
16015
  outline: none; /* programmatic focus may land here briefly before being redirected to close button */
16014
16016
  opacity: 0;
16015
16017
  /* Positioned with plain left/top (applyNewPosition, visible_rect.js) —
16016
- left/top's own duration is driven by --popup-position-transition-duration,
16017
- same mechanism Popover/Dialog use (see their own CSS): 0s (no
16018
- transition) for most repositions, a real duration only when the
16019
- reposition was itself triggered by a resize. */
16020
- transition:
16021
- opacity 0.2s ease-in-out,
16022
- left var(--popup-position-transition-duration, 0s) ease-out,
16023
- top var(--popup-position-transition-duration, 0s) ease-out;
16018
+ left/top are NOT transitioned here, applyNewPosition drives that
16019
+ itself via the Web Animations API instead of CSS, same mechanism
16020
+ Popover/Dialog use. */
16021
+ transition: opacity 0.2s ease-in-out;
16024
16022
  cursor: initial; /* Do not inherit element cursor, inside the element but should use regular cursor */
16025
16023
  pointer-events: auto; /* Must be interactive to be closabled (overrid list item pointer-events none for instance) */
16026
16024
  overflow: visible;
16027
16025
 
16026
+ &[data-anchor-scrolls] {
16027
+ position: absolute;
16028
+ }
16029
+
16028
16030
  &[data-status="success"] {
16029
16031
  --x-callout-status-color: var(--callout-success-color);
16030
16032
  }
@@ -16791,6 +16793,24 @@ const positionCallout = (calloutElement, anchorElement, {
16791
16793
  calloutElement.setAttribute("data-anchor-box", alignToAnchorBox);
16792
16794
  }
16793
16795
 
16796
+ // Drives the callout's own position: fixed/absolute switch (see
16797
+ // .navi_callout's own CSS) — same reasoning as Popover's identical
16798
+ // data-anchor-scrolls attribute (popover.jsx): true only for a real
16799
+ // anchor that itself scrolls with the document — that's the one case
16800
+ // absolute (scrolling in lockstep with it) is correct; not just "has an
16801
+ // anchor at all", since an anchor that's itself position: fixed, or
16802
+ // nested inside something that is (e.g. anchored to an element inside a
16803
+ // Popover/Dialog rendered in the top layer), stays pinned to the viewport
16804
+ // regardless of document scroll too, so the callout must stay fixed right
16805
+ // alongside it instead — switching to absolute there would make it drift
16806
+ // away from an anchor that never actually moves on scroll. No anchor at
16807
+ // all (docked/centered in the viewport) also stays fixed, the default.
16808
+ if (anchorElement && !findSelfOrAncestorFixedPosition(anchorElement)) {
16809
+ calloutElement.setAttribute("data-anchor-scrolls", "");
16810
+ } else {
16811
+ calloutElement.removeAttribute("data-anchor-scrolls");
16812
+ }
16813
+
16794
16814
  // Set initial border styles
16795
16815
  calloutBoxElement.style.borderWidth = `${BORDER_WIDTH}px`;
16796
16816
  calloutFrameElement.style.left = `-${BORDER_WIDTH}px`;
@@ -17893,6 +17913,7 @@ const matchBestLang = (lang, languageMap) => {
17893
17913
  * - `"time.less_than_minute"` — e.g. "in less than a minute"
17894
17914
  * - `"time.ongoing"` — e.g. "Ongoing"
17895
17915
  * - `"time.tomorrow_at"` — e.g. "[day] at [time]" ([day] and [time] are placeholders)
17916
+ * - `"time.midnight"` — e.g. "midnight"
17896
17917
  *
17897
17918
  * @example
17898
17919
  * import { naviI18n } from "@jsenv/navi";
@@ -17986,6 +18007,24 @@ naviI18n.addAll({
17986
18007
  pt: "em [duration]",
17987
18008
  nl: "over [duration]",
17988
18009
  },
18010
+ // Substituted in place of the "0 heure(s)" part of an Intl-generated
18011
+ // duration string when <Time type="time" format="long"> renders midnight
18012
+ // — see time.jsx's own TimeTime for why midnight can't just fall through
18013
+ // to formatMinuteDuration like every other hour does, and how this word
18014
+ // gets spliced in (formatToParts, not string concatenation) so the rest
18015
+ // of the sentence (conjunction, minutes) still comes out in whatever
18016
+ // grammar/word order this language's own Intl.DurationFormat produces.
18017
+ // Languages without an entry here fall back to that language's own
18018
+ // literal "0 heure(s)" wording instead (see TimeTime), never to this key.
18019
+ "time.midnight": {
18020
+ en: "midnight",
18021
+ fr: "minuit",
18022
+ de: "Mitternacht",
18023
+ es: "medianoche",
18024
+ it: "mezzanotte",
18025
+ pt: "meia-noite",
18026
+ nl: "middernacht",
18027
+ },
17989
18028
  // Compact duration unit symbols used in "1h30", "45min", "2d", etc.
17990
18029
  "time.duration.year_symbol": {
17991
18030
  en: "y",
@@ -18368,24 +18407,24 @@ naviI18n.addAll({
18368
18407
  en: "The date must be on or before <strong>[max]</strong>.",
18369
18408
  },
18370
18409
  "constraint.max.number.default": {
18371
- fr: "Ce nombre doit être <strong>[max]</strong> ou moins.",
18372
- en: "This number must be <strong>[max]</strong> or less.",
18410
+ fr: "Max <strong>[max]</strong>.",
18411
+ en: "Max <strong>[max]</strong>.",
18373
18412
  },
18374
18413
  "constraint.max.hour.default": {
18375
- fr: "Le nombre d'heures doit être <strong>[max]</strong> ou moins.",
18376
- en: "The number of hours must be <strong>[max]</strong> or less.",
18414
+ fr: "Max <strong>[max]</strong> heures.",
18415
+ en: "Max <strong>[max]</strong> hours.",
18377
18416
  },
18378
18417
  "constraint.max.minute.default": {
18379
- fr: "Le nombre de minutes doit être <strong>[max]</strong> ou moins.",
18380
- en: "The number of minutes must be <strong>[max]</strong> or less.",
18418
+ fr: "Max <strong>[max]</strong> minutes.",
18419
+ en: "Max <strong>[max]</strong> minutes.",
18381
18420
  },
18382
18421
  "constraint.max.second.default": {
18383
- fr: "Le nombre de secondes doit être <strong>[max]</strong> ou moins.",
18384
- en: "The number of seconds must be <strong>[max]</strong> or less.",
18422
+ fr: "Max <strong>[max]</strong> secondes.",
18423
+ en: "Max <strong>[max]</strong> secondes.",
18385
18424
  },
18386
18425
  "constraint.max.percentage.default": {
18387
- fr: "Le pourcentage doit être <strong>[max]</strong> ou moins.",
18388
- en: "The percentage must be <strong>[max]</strong> or less.",
18426
+ fr: "Max <strong>[max]</strong>%.",
18427
+ en: "Max <strong>[max]</strong>%.",
18389
18428
  },
18390
18429
  "constraint.step.number.default": {
18391
18430
  fr: "Ce nombre doit être un multiple de <strong>[step]</strong> (par ex. <strong>[before]</strong> ou <strong>[after]</strong>).",
@@ -19165,7 +19204,15 @@ const formatTime = (date, lang) => {
19165
19204
  * "compact" uses our own notation that omits the minute symbol when hours are present.
19166
19205
  *
19167
19206
  * @param {number} minutes
19168
- * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact" }} [options]
19207
+ * @param {{ lang?: string, format?: "long"|"short"|"narrow"|"compact", alwaysShowHours?: boolean }} [options]
19208
+ * @param {boolean} [options.alwaysShowHours=false] - Normally a zero-hours
19209
+ * component is dropped entirely (a real 5-minute duration should print as
19210
+ * "5 minutes", not "0 hours 5 minutes") — set this to keep it (e.g. "0 h
19211
+ * et 5 min"/"0h 5min"/"0h05") instead. Only meaningful when `minutes` is
19212
+ * itself less than 60, i.e. the hours component would otherwise be zero;
19213
+ * used by `<Time type="time">` for a time-of-day at midnight, where
19214
+ * dropping the hour would make it indistinguishable from an actual
19215
+ * duration (see time.jsx's own TimeTime).
19169
19216
  *
19170
19217
  * @example
19171
19218
  * formatMinuteDuration(90, { lang: "fr" }) // "1 heure 30 minutes" (long, default)
@@ -19173,16 +19220,20 @@ const formatTime = (date, lang) => {
19173
19220
  * formatMinuteDuration(90, { lang: "fr", format: "narrow" }) // "1h 30min" (Intl narrow)
19174
19221
  * formatMinuteDuration(90, { lang: "fr", format: "compact" }) // "1h30" (custom, no minute symbol)
19175
19222
  * formatMinuteDuration(45, { lang: "en", format: "compact" }) // "45min"
19223
+ * formatMinuteDuration(5, { lang: "fr", format: "narrow", alwaysShowHours: true }) // "0h 5min"
19176
19224
  */
19177
19225
  const formatMinuteDuration = (
19178
19226
  minutes,
19179
- { lang = languagesSignal.value, format = "long" } = {},
19227
+ { lang = languagesSignal.value, format = "long", alwaysShowHours = false } = {},
19180
19228
  ) => {
19181
19229
  const h = Math.floor(minutes / 60);
19182
19230
  const m = minutes % 60;
19183
19231
  if (format !== "compact" && typeof Intl.DurationFormat !== "undefined") {
19184
- const fmt = new Intl.DurationFormat(lang, { style: format }); // "long", "short", or "narrow"
19185
- if (h === 0) {
19232
+ const fmt = new Intl.DurationFormat(lang, {
19233
+ style: format, // "long", "short", or "narrow"
19234
+ ...(alwaysShowHours ? { hoursDisplay: "always" } : {}),
19235
+ });
19236
+ if (h === 0 && !alwaysShowHours) {
19186
19237
  return fmt.format({ minutes: m });
19187
19238
  }
19188
19239
  if (m === 0) {
@@ -19193,7 +19244,7 @@ const formatMinuteDuration = (
19193
19244
  // format="compact": "1h30", "45min", "2h" — no minute symbol when hours are present
19194
19245
  const hSym = naviI18n("time.duration.hour_symbol", undefined, { lang });
19195
19246
  const mSym = naviI18n("time.duration.minute_symbol", undefined, { lang });
19196
- if (h === 0) {
19247
+ if (h === 0 && !alwaysShowHours) {
19197
19248
  return `${m}${mSym}`;
19198
19249
  }
19199
19250
  if (m === 0) {
@@ -19340,40 +19391,50 @@ const formatDuration = (
19340
19391
  // Fall through to compact notation when values are non-numeric or mixed-sign
19341
19392
  }
19342
19393
 
19394
+ // A component explicitly present but numerically zero (e.g. the demo's own
19395
+ // { hours: 0, minutes: 5 }) conveys no information for a genuine duration
19396
+ // — same convention formatMinuteDuration/formatSecondDuration already
19397
+ // follow (checking h > 0/m > 0, not merely "was a value passed") — so
19398
+ // it's dropped here too, regardless of whether the caller included the
19399
+ // key at all. Non-numeric mid-edit values (e.g. "2a") still count as
19400
+ // present — Number("2a") is NaN, never === 0 — so those keep rendering
19401
+ // as-is with their own unit symbol.
19402
+ const hasNonZero = (key) => has(key) && Number(duration[key]) !== 0;
19403
+
19343
19404
  const sym = (key) =>
19344
19405
  naviI18n(`time.duration.${key}_symbol`, undefined, { lang });
19345
19406
  const parts = [];
19346
19407
 
19347
- if (has("years")) {
19408
+ if (hasNonZero("years")) {
19348
19409
  parts.push(`${duration.years}${sym("year")}`);
19349
19410
  }
19350
- if (has("months")) {
19411
+ if (hasNonZero("months")) {
19351
19412
  parts.push(`${duration.months}${sym("month")}`);
19352
19413
  }
19353
- if (has("weeks")) {
19414
+ if (hasNonZero("weeks")) {
19354
19415
  parts.push(`${duration.weeks}${sym("week")}`);
19355
19416
  }
19356
- if (has("days")) {
19417
+ if (hasNonZero("days")) {
19357
19418
  parts.push(`${duration.days}${sym("day")}`);
19358
19419
  }
19359
19420
 
19360
19421
  // Hours + minutes: when both present, pad minutes to 2 digits after the h symbol
19361
19422
  const hSym = sym("hour");
19362
19423
  const mSym = sym("minute");
19363
- if (has("hours") && has("minutes")) {
19424
+ if (hasNonZero("hours") && hasNonZero("minutes")) {
19364
19425
  parts.push(
19365
19426
  `${duration.hours}${hSym}${String(duration.minutes).padStart(2, "0")}`,
19366
19427
  );
19367
- } else if (has("hours")) {
19428
+ } else if (hasNonZero("hours")) {
19368
19429
  parts.push(`${duration.hours}${hSym}`);
19369
- } else if (has("minutes")) {
19430
+ } else if (hasNonZero("minutes")) {
19370
19431
  parts.push(`${duration.minutes}${mSym}`);
19371
19432
  }
19372
19433
 
19373
- if (has("seconds")) {
19434
+ if (hasNonZero("seconds")) {
19374
19435
  parts.push(`${duration.seconds}${sym("second")}`);
19375
19436
  }
19376
- if (has("milliseconds")) {
19437
+ if (hasNonZero("milliseconds")) {
19377
19438
  parts.push(`${duration.milliseconds}${sym("millisecond")}`);
19378
19439
  }
19379
19440
  return parts.join("") || "0";
@@ -21420,9 +21481,9 @@ const tryActionAfterInteractionAllowed = (
21420
21481
  * - No <dialog>/<details>/[popover] ancestor → runs like a normal
21421
21482
  * useLayoutEffect with the provided deps.
21422
21483
  * - Inside a closed/hidden ancestor → skips the initial run; instead runs
21423
- * the callback once the ancestor opens — see addBeforePaintOpenCallback
21424
- * below for exactly how that's detected, and why it matters that it
21425
- * happens before the browser paints.
21484
+ * the callback once the ancestor opens — see @jsenv/dom's own
21485
+ * observeAncestorOpenState for exactly how that's detected, and why it
21486
+ * matters that it happens before the browser paints.
21426
21487
  * - Inside an open ancestor → runs on mount AND every subsequent open.
21427
21488
  *
21428
21489
  * The callback's second argument is always a `navi_displayed` CustomEvent,
@@ -21430,9 +21491,7 @@ const tryActionAfterInteractionAllowed = (
21430
21491
  * - No <dialog>/<details>/[popover]/[aria-expanded] ancestor at all →
21431
21492
  * `{ ancestor: document, ancestorType: "document" }`.
21432
21493
  * - Otherwise → `{ ancestor: <the matched element>, ancestorType: "dialog"
21433
- * | "popover" | "details" | "aria-expanded" }`. On the toggle fallback
21434
- * path (see addBeforePaintOpenCallback) `detail.event` is also set to
21435
- * the native `toggle` event that triggered it.
21494
+ * | "popover" | "details" | "aria-expanded" }`.
21436
21495
  * Consumers that only care about a genuine top-level, no-ancestor mount
21437
21496
  * (e.g. use_auto_focus.js — an ancestor opening already has its own
21438
21497
  * transferFocus/openEffect placing focus, so re-running a per-element
@@ -21460,20 +21519,17 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
21460
21519
  if (!el) {
21461
21520
  return;
21462
21521
  }
21463
- const ancestor = el.closest("dialog, details, [popover], [aria-expanded]");
21522
+ const ancestor = closestOpenableAncestor(el);
21464
21523
  if (!ancestor) {
21465
- callbackRef.current(el, createDisplayedEvent(document, "document"));
21524
+ callbackRef.current(el, createDisplayedEvent(document));
21466
21525
  return;
21467
21526
  }
21468
21527
  if (!isAncestorOpen(ancestor)) {
21469
- // Ancestor is closed — skip now; addBeforePaintOpenCallback below
21470
- // will fire once it opens.
21528
+ // Ancestor is closed — skip now; the observeAncestorOpenState call
21529
+ // below will fire once it opens.
21471
21530
  return;
21472
21531
  }
21473
- callbackRef.current(
21474
- el,
21475
- createDisplayedEvent(ancestor, getAncestorType(ancestor)),
21476
- );
21532
+ callbackRef.current(el, createDisplayedEvent(ancestor));
21477
21533
  }, deps);
21478
21534
 
21479
21535
  // Re-run every time the ancestor opens.
@@ -21482,134 +21538,27 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
21482
21538
  if (!el) {
21483
21539
  return undefined;
21484
21540
  }
21485
- const ancestor = el.closest("dialog, details, [popover], [aria-expanded]");
21541
+ const ancestor = closestOpenableAncestor(el);
21486
21542
  if (!ancestor) {
21487
21543
  return undefined;
21488
21544
  }
21489
- return addBeforePaintOpenCallback(ancestor, (event) => {
21545
+ return observeAncestorOpenState(ancestor, ({ isOpen }) => {
21546
+ if (!isOpen) {
21547
+ return;
21548
+ }
21490
21549
  const lastEl = ref.current;
21491
- callbackRef.current(lastEl, event);
21550
+ callbackRef.current(lastEl, createDisplayedEvent(ancestor));
21492
21551
  });
21493
21552
  }, []);
21494
21553
  };
21495
21554
 
21496
- const isAncestorOpen = (ancestor) => {
21497
- if (ancestor.tagName === "DIALOG" || ancestor.hasAttribute("popover")) {
21498
- return ancestor.matches(":popover-open, [open]");
21499
- }
21500
- if (ancestor.tagName === "DETAILS") {
21501
- return ancestor.open;
21502
- }
21503
- return ancestor.getAttribute("aria-expanded") === "true";
21504
- };
21505
-
21506
- const getAncestorType = (ancestor) => {
21507
- if (ancestor.tagName === "DIALOG") {
21508
- return "dialog";
21509
- }
21510
- if (ancestor.hasAttribute("popover")) {
21511
- return "popover";
21512
- }
21513
- if (ancestor.tagName === "DETAILS") {
21514
- return "details";
21515
- }
21516
- return `${ancestor.tagName}[aria-expanded]`;
21517
- };
21518
-
21519
- const createDisplayedEvent = (ancestor, ancestorType, eventInit) => {
21555
+ const createDisplayedEvent = (ancestor) => {
21520
21556
  return new CustomEvent("navi_displayed", {
21521
- detail: { ancestor, ancestorType, ...eventInit },
21522
- });
21523
- };
21524
-
21525
- /**
21526
- * Notifies `callback` the moment `ancestor` opens — timed to land strictly
21527
- * before the browser's next paint, so a caller doing layout/measurement work
21528
- * in response (e.g. text_anchor.jsx repositioning a badge) never flashes the
21529
- * still-closed state first.
21530
- *
21531
- * We deliberately do NOT use the native `toggle` event as the primary
21532
- * signal, even though every <dialog>/<details>/[popover] fires one: per the
21533
- * WHATWG spec it's dispatched via a *queued task* ("queue a popover toggle
21534
- * event task"), not synchronously and not as a microtask. The element's
21535
- * shown state itself (showPopover()/showModal()) still flips synchronously,
21536
- * so the browser can — and does — paint it in its default, uncorrected
21537
- * position before that queued task ever runs. Relying on `toggle` alone
21538
- * means the correction always arrives one paint late.
21539
- *
21540
- * Instead we watch `open`/`aria-expanded` via MutationObserver:
21541
- * - <dialog>/<details> reflect `open` themselves, natively, synchronously.
21542
- * - This codebase's own Popover.jsx sets `aria-expanded` synchronously in
21543
- * the same call stack as showPopover() (see popover.jsx's own
21544
- * aria-expanded comments) — not part of any web standard, just this
21545
- * library's own convention, but reliable for anything built through it.
21546
- * MutationObserver callbacks run as a microtask, strictly before paint —
21547
- * exactly the timing we need, no ambiguity.
21548
- *
21549
- * The `toggle` listener is kept as a fallback, attached ONLY where the
21550
- * MutationObserver above has no chance of ever firing: a bare [popover]
21551
- * element with no `aria-expanded` of its own — i.e. one not built through
21552
- * this codebase's own Popover.jsx (the only thing that reliably sets it).
21553
- * That's the one case this file's author could think of with no other
21554
- * synchronously-observable signal at all. It still arrives a paint late,
21555
- * but a late correction beats none.
21556
- *
21557
- * This relies on `aria-expanded` genuinely being present on a navi Popover
21558
- * by the time this code runs — see popover.jsx's own comments on why it
21559
- * sets that attribute the way it does.
21560
- *
21561
- * @param {Element} ancestor
21562
- * @param {(event: Event) => void} callback
21563
- * @returns {() => void} cleanup — removes the observer/listener
21564
- */
21565
- const addBeforePaintOpenCallback = (ancestor, callback) => {
21566
- const ancestorType = getAncestorType(ancestor);
21567
- const needsToggleFallback =
21568
- ancestor.hasAttribute("popover") && !ancestor.hasAttribute("aria-expanded");
21569
- if (needsToggleFallback) {
21570
- const onToggle = (toggleEvent) => {
21571
- if (!isAncestorOpen(ancestor)) {
21572
- return;
21573
- }
21574
- callback(
21575
- createDisplayedEvent(ancestor, ancestorType, { event: toggleEvent }),
21576
- );
21577
- };
21578
- ancestor.addEventListener("toggle", onToggle);
21579
- return () => {
21580
- ancestor.removeEventListener("toggle", onToggle);
21581
- };
21582
- }
21583
-
21584
- // Edge-triggered on purpose: some consumers (e.g. Popover.jsx) set
21585
- // aria-expanded both imperatively (in their own openEffect, for precise
21586
- // ordering relative to forced reflows/transitions) AND declaratively via a
21587
- // JSX prop derived from the same open state — the latter is a deliberate
21588
- // "always reflect current truth" prop, but Preact diffs against its own
21589
- // previous *rendered* value, not the live DOM, so any later re-render that
21590
- // happens to occur while already open re-applies the same "true" value as
21591
- // a genuinely new attribute mutation. Tracking wasOpen here collapses that
21592
- // redundant open→open mutation instead of re-running callback a second
21593
- // time for the same open.
21594
- let wasOpen = isAncestorOpen(ancestor);
21595
- const observer = new MutationObserver(() => {
21596
- const isOpen = isAncestorOpen(ancestor);
21597
- if (isOpen === wasOpen) {
21598
- return;
21599
- }
21600
- wasOpen = isOpen;
21601
- if (!isOpen) {
21602
- return;
21603
- }
21604
- callback(createDisplayedEvent(ancestor, ancestorType));
21605
- });
21606
- observer.observe(ancestor, {
21607
- attributes: true,
21608
- attributeFilter: ["open", "aria-expanded"],
21557
+ detail: {
21558
+ ancestor,
21559
+ ancestorType: getAncestorOpenType(ancestor),
21560
+ },
21609
21561
  });
21610
- return () => {
21611
- observer.disconnect();
21612
- };
21613
21562
  };
21614
21563
 
21615
21564
  // see also https://github.com/preactjs/preact/issues/1255
@@ -36232,6 +36181,42 @@ const useOpenController = (openHandler) => {
36232
36181
  return controllerRef.current;
36233
36182
  };
36234
36183
 
36184
+ // Nested popups that both mount already-open (`open`/`defaultOpen`) would
36185
+ // otherwise stack in the wrong order: Preact fires layout effects
36186
+ // child-first on mount, so a nested popup's own silent mount-open would call
36187
+ // showPopover() before its ancestor's — and the top layer stacks *later*
36188
+ // showPopover() calls above *earlier* ones (see popover.jsx's own openEffect
36189
+ // comment) — leaving the ancestor on top instead of the nested popup, the
36190
+ // opposite of what opening them one at a time (ancestor first, by real user
36191
+ // interaction) would produce. Batching every mount-time silent open queued
36192
+ // during the same commit's layout-effect phase into one microtask flush,
36193
+ // then simply running them in *reverse* of their registration order fixes
36194
+ // this — no need to compare DOM positions: since effects already fire
36195
+ // child-first, tree-wide, for *any* ancestor/descendant pair the descendant
36196
+ // is always queued before the ancestor, regardless of what else is in the
36197
+ // tree, so reversing the whole batch always puts every ancestor before its
36198
+ // own descendants. Works for any nesting depth for the same reason. Two
36199
+ // unrelated (sibling) popups both mounting open also get reordered
36200
+ // relative to each other, but there's no meaningful "correct" order between
36201
+ // those anyway.
36202
+ let pendingSilentOpens = [];
36203
+ let silentOpenFlushScheduled = false;
36204
+ const scheduleSilentOpen = (run) => {
36205
+ pendingSilentOpens.push(run);
36206
+ if (silentOpenFlushScheduled) {
36207
+ return;
36208
+ }
36209
+ silentOpenFlushScheduled = true;
36210
+ queueMicrotask(() => {
36211
+ const entries = pendingSilentOpens;
36212
+ pendingSilentOpens = [];
36213
+ silentOpenFlushScheduled = false;
36214
+ for (let i = entries.length - 1; i >= 0; i--) {
36215
+ entries[i]();
36216
+ }
36217
+ });
36218
+ };
36219
+
36235
36220
  /**
36236
36221
  * Keeps an open controller in sync with a plain `open`/`defaultOpen` pair —
36237
36222
  * shared between `useOpenControllerByProps` below (Dialog/Popover driving
@@ -36262,9 +36247,15 @@ const useOpenPropsEffectOnOpenController = (openController, props) => {
36262
36247
  // silent: true — nothing was ever shown as "closed" for the user to
36263
36248
  // see transition away from, so this first open skips the entrance
36264
36249
  // animation entirely (see popover.jsx's own openEffect for how).
36265
- openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
36266
- silent: true,
36267
- });
36250
+ // Deferred + batched (see scheduleSilentOpen above) rather than
36251
+ // called directly, so nested popups that both mount already-open
36252
+ // end up stacked ancestor-first instead of Preact's own child-first
36253
+ // effect order.
36254
+ scheduleSilentOpen(() =>
36255
+ openController.open(new CustomEvent("open_by_prop", { detail: {} }), {
36256
+ silent: true,
36257
+ }),
36258
+ );
36268
36259
  }
36269
36260
  return;
36270
36261
  }
@@ -36394,25 +36385,17 @@ const popupCss = /* css */ `
36394
36385
 
36395
36386
  .navi_popover,
36396
36387
  .navi_dialog {
36397
- /* left/top folded into this same list (not left to each of Popover's/
36398
- Dialog's own base rule alone) a more specific selector's own
36399
- transition-property replaces the base rule's list wholesale rather
36400
- than adding to it, so left/top's own transition would otherwise stop
36401
- applying for as long as navi-animation is set (which, unlike the
36402
- open/close transition itself, is the element's *entire* open
36403
- duration — see openEffect in popover.jsx/dialog.jsx). Duration is
36404
- driven by --popup-position-transition-duration either way — see
36405
- applyNewPosition's own doc in visible_rect.js for what sets it and
36406
- why. */
36388
+ /* left/top are deliberately absent from this list applyNewPosition
36389
+ (visible_rect.js) drives that transition itself via the Web
36390
+ Animations API instead of CSS, so it stays independent of whatever
36391
+ this list contains (no shared transition-property to clobber, no
36392
+ propertyName to filter). */
36407
36393
  &[navi-animation] {
36408
- transition-property:
36409
- display, overlay, opacity, translate, scale, box-shadow, left, top;
36394
+ transition-property: display, overlay, opacity, translate, scale, box-shadow;
36410
36395
  transition-duration:
36411
36396
  var(--popup-animation-duration), var(--popup-animation-duration),
36412
36397
  var(--popup-opacity-duration), var(--popup-translate-duration),
36413
- var(--popup-scale-duration), var(--popup-animation-duration),
36414
- var(--popup-position-transition-duration, 0s),
36415
- var(--popup-position-transition-duration, 0s);
36398
+ var(--popup-scale-duration), var(--popup-animation-duration);
36416
36399
  transition-timing-function: ease;
36417
36400
  transition-behavior: allow-discrete;
36418
36401
  }
@@ -36858,13 +36841,11 @@ const css$s = /* css */`
36858
36841
  outline-color: var(--dialog-outline-color);
36859
36842
  outline-offset: 0;
36860
36843
  box-shadow: var(--dialog-box-shadow);
36861
- /* Duration driven by applyNewPosition (visible_rect.js) — 0s (no
36862
- transition) for most repositions (scroll, in particular, needs to
36863
- track its target in lockstep), a real duration only when the
36864
- reposition was itself triggered by a resize. */
36865
- transition-property: left, top;
36866
- transition-duration: var(--popup-position-transition-duration, 0s);
36867
- transition-timing-function: ease-out;
36844
+ /* left/top are NOT transitioned here — applyNewPosition (visible_rect.js)
36845
+ drives that itself via the Web Animations API instead of CSS, so it
36846
+ stays independent from navi-animation's own opacity/scale/display
36847
+ transition list below (no shared transition-property to clobber, no
36848
+ propertyName to filter). */
36868
36849
 
36869
36850
  &::backdrop {
36870
36851
  background: var(--navi-backdrop-close-background);
@@ -37028,10 +37009,12 @@ const css$s = /* css */`
37028
37009
  * value is used as-is.
37029
37010
  * @param {string} [props.animationDuration] - Maps to
37030
37011
  * `--popup-animation-duration`.
37031
- * @param {Element|{current: Element}} [props.anchor] - Only ever sizes the
37032
- * dialog via the `--anchor-width`/`--anchor-height` CSS vars — never used
37033
- * for positioning (see this file's top comment). Defaults to whatever
37034
- * triggered the open (`e.detail.anchor`), if any.
37012
+ * @param {Element|{current: Element}|string} [props.anchor] - Only ever sizes
37013
+ * the dialog via the `--anchor-width`/`--anchor-height` CSS vars — never
37014
+ * used for positioning (see this file's top comment). Defaults to whatever
37015
+ * triggered the open (`e.detail.anchor`), if any. A string is resolved via
37016
+ * `document.getElementById` when the dialog opens — see popover.jsx's own
37017
+ * `anchor` doc for why (mainly `defaultOpen`).
37035
37018
  * @param {string} [props.minWidth] - Maps to `--dialog-min-width`; clamped
37036
37019
  * so it can never push the dialog past `--dialog-maxmax-width` (the
37037
37020
  * viewport/container-spacing ceiling) regardless of how large a value is
@@ -37235,11 +37218,25 @@ const useDialogProps = props => {
37235
37218
  // `open`/`defaultOpen` already being truthy at mount — see popover.jsx's
37236
37219
  // own openEffect for the full reasoning, mirrored here identically.
37237
37220
  const silent = Boolean(e.detail.silent);
37238
- const positionedAncestor = isModal ? null : getPositionedParent(dialogEl.parentElement /* dialogEl is inside the clip_wrapper */);
37221
+
37222
+ // document.documentElement — the shared "no real container, use the
37223
+ // viewport" sentinel (see offset_parent.js) — not just
37224
+ // getPositionedParent(dialogEl) unconditionally: that walks starting
37225
+ // from dialogEl.parentElement, which for DialogLocal is the
37226
+ // .navi_dialog_clip_wrapper (itself position: absolute) rather than the
37227
+ // real, meaningful ancestor beyond it.
37228
+ const positionedAncestor = isModal ? document.documentElement : getPositionedParent(dialogEl.parentElement /* dialogEl is inside the clip_wrapper */);
37239
37229
  const [cleanup, addCleanup] = createPubSub(true);
37240
37230
  let anchorElement;
37241
37231
  if (typeof anchor === "string") {
37242
- console.warn(`Dialog: anchor="${anchor}" is no longer supported anchor only accepts a ref or a DOM element now (or omit it entirely).`);
37232
+ // Resolved at open time (not render time) via getElementById mainly
37233
+ // for defaultOpen, where there's no triggering event/ref yet to read a
37234
+ // real element from, only an id known up front. See popover.jsx's own
37235
+ // anchor handling for the full reasoning, mirrored here identically.
37236
+ anchorElement = document.getElementById(anchor);
37237
+ if (!anchorElement) {
37238
+ console.warn(`Dialog: anchor="${anchor}" did not match any element`);
37239
+ }
37243
37240
  } else if (anchor) {
37244
37241
  // anchor prop is a ref or a DOM element
37245
37242
  anchorElement = anchor.current ?? anchor;
@@ -37324,31 +37321,47 @@ const useDialogProps = props => {
37324
37321
  // custom renderer. applyNewPosition sets --container-position-remaining-height/-width
37325
37322
  // from the result, same as popover.jsx.
37326
37323
  const positionDialog = triggerEvent => {
37327
- const position = pickPositionRelativeTo(dialogEl, null, {
37324
+ const pickOptions = {
37328
37325
  positionArea,
37329
- container: isModal ? undefined : positionedAncestor,
37326
+ container: positionedAncestor,
37330
37327
  marginWithContainer: resolveSpacingSize(marginWithContainer),
37331
37328
  event: triggerEvent
37332
- });
37329
+ };
37330
+ let position = pickPositionRelativeTo(dialogEl, null, pickOptions);
37333
37331
  applyNewPosition(dialogEl, position);
37334
- // Lets a descendant's own visibleRectEffect (visible_rect.js — e.g. a
37335
- // Callout anchored to something inside this Dialog) know to recheck
37336
- // its own position whenever this dialog itself movesit already
37337
- // walks up the ancestor chain and listens for this event on any
37338
- // <dialog> ancestor specifically, no wiring needed on that side.
37339
- dispatchCustomEvent(dialogEl, "navi_position_change");
37332
+ // applyNewPosition above just set --container-position-remaining-
37333
+ // width/height to the real available space narrower than whatever
37334
+ // dialogEl measured at just before (nothing, on a first open see
37335
+ // popover.jsx's own identical comment on its own positionPopover for
37336
+ // the full reasoning, mirrored here). If that changes dialogEl's own
37337
+ // rendered box (its content rewraps once truly constrained), the
37338
+ // position picked above was computed against the wrong (wider,
37339
+ // shorter) box and needs a synchronous second pass, or it paints one
37340
+ // frame too high/low before the ResizeObserver watching this same
37341
+ // element (rectEffect.observeSize below) ever gets a chance to
37342
+ // correct it — that one only reacts on the *next* animation frame.
37343
+ if (dialogEl.offsetWidth !== position.width || dialogEl.offsetHeight !== position.height) {
37344
+ position = pickPositionRelativeTo(dialogEl, null, pickOptions);
37345
+ applyNewPosition(dialogEl, position);
37346
+ }
37347
+ // A descendant's own visibleRectEffect (visible_rect.js — e.g. a
37348
+ // Callout anchored to something inside this Dialog) knowing to
37349
+ // recheck its own position whenever this dialog itself moves is
37350
+ // handled generically by applyNewPosition itself (dispatches
37351
+ // navi_position_change on every call) — nothing to do here.
37340
37352
  };
37341
37353
  positionDialog();
37342
37354
 
37343
37355
  // Reposition on the same triggers Popover's own visibleRectEffect
37344
37356
  // already reacts to generically — window resize/scroll/visual-viewport
37345
- // changes for layer="top"/isModal (watching document.documentElement;
37357
+ // changes for layer="top"/isModal (positionedAncestor is already
37358
+ // document.documentElement there, see its own computation above;
37346
37359
  // visibleRectEffect already debounces visualViewport resize by 100ms
37347
37360
  // to avoid the mobile tap-to-tap-input keyboard flicker, so no
37348
37361
  // separate mechanism is needed here for that), or the positioned
37349
- // ancestor's own resize for layer="local" (watching positionedAncestor)
37350
- // — see this file's top comment.
37351
- const rectEffect = visibleRectEffect(isModal ? document.documentElement : positionedAncestor, (visibleRect, {
37362
+ // ancestor's own resize for layer="local" see this file's top
37363
+ // comment.
37364
+ const rectEffect = visibleRectEffect(positionedAncestor, (visibleRect, {
37352
37365
  event
37353
37366
  }) => {
37354
37367
  positionDialog(event);
@@ -37360,6 +37373,14 @@ const useDialogProps = props => {
37360
37373
  addCleanup(() => {
37361
37374
  rectEffect.disconnect();
37362
37375
  });
37376
+ // A descendant anchored to something inside this dialog (a Callout, a
37377
+ // nested Popover) needing to know about this dialog's own left/top
37378
+ // repositioning transition — not just that the target changed
37379
+ // (navi_position_change above), but that a real, currently-playing
37380
+ // transition is moving it right now — is handled generically by
37381
+ // applyNewPosition itself (see its own notifyPositionTransition), since
37382
+ // positionDialog already goes through it above; nothing to wire up
37383
+ // here.
37363
37384
 
37364
37385
  // Final commit — see popover.jsx's own openEffect for the full
37365
37386
  // reasoning behind the `silent` ordering swap (forced reflow between
@@ -37395,6 +37416,20 @@ const useDialogProps = props => {
37395
37416
  if (mouseDownEvent.button !== 0) {
37396
37417
  return;
37397
37418
  }
37419
+ // Real DOM containment wins over the coordinate check below — an
37420
+ // element genuinely inside the dialog (e.g. one with `overflow:
37421
+ // visible`, a negative margin, or an absolutely-positioned child)
37422
+ // can be visually painted outside dialogEl's own bounding rect, and
37423
+ // a click there must not count as "outside" just because its
37424
+ // coordinates fall outside that rect. Excludes dialogEl itself
37425
+ // though (contains() is true for the element itself, not just real
37426
+ // descendants) — a genuine backdrop click reports target === dialogEl
37427
+ // (there's no real ::backdrop node to be the target), so treating
37428
+ // that case as "contained" would make the coordinate check below
37429
+ // (the actual backdrop-vs-dialog-padding distinction) never run.
37430
+ if (mouseDownEvent.target !== dialogEl && dialogEl.contains(mouseDownEvent.target)) {
37431
+ return;
37432
+ }
37398
37433
  const rect = dialogEl.getBoundingClientRect();
37399
37434
  const isOutside = mouseDownEvent.clientX < rect.left || mouseDownEvent.clientX > rect.right || mouseDownEvent.clientY < rect.top || mouseDownEvent.clientY > rect.bottom;
37400
37435
  if (!isOutside) {
@@ -37611,11 +37646,17 @@ installImportMetaCssBuild(import.meta);/**
37611
37646
  * popover's own content for attention.
37612
37647
  *
37613
37648
  * The via-attribute renderer defaults to `position: fixed`, overridden to
37614
- * `absolute` only when there's a real anchor: a real anchor needs the
37615
- * popover to scroll in lockstep with the document to stay visually attached
37616
- * to it, whereas `fixed` is the more direct way to stay pinned to the
37617
- * viewport when there's none (and avoids ever extending the document's own
37618
- * scrollable area).
37649
+ * `absolute` only when there's a real anchor that itself scrolls with the
37650
+ * document: such an anchor needs the popover to scroll in lockstep with it
37651
+ * to stay visually attached, whereas `fixed` is the more direct way to stay
37652
+ * pinned to the viewport when there's no anchor (and avoids ever extending
37653
+ * the document's own scrollable area). When the anchor itself doesn't
37654
+ * scroll with the document — it's `position: fixed`, or nested inside
37655
+ * something that is, e.g. anchored to an element inside another top-layer
37656
+ * popover/modal dialog — staying `fixed` is correct *despite* having a real
37657
+ * anchor too, since the anchor never moves on scroll either; see
37658
+ * `data-anchor-scrolls` in openEffect/the CSS below for how that's detected
37659
+ * and applied.
37619
37660
  */
37620
37661
  let openLocalPopoverCount = 0;
37621
37662
  const css$r = /* css */`
@@ -37707,13 +37748,11 @@ const css$r = /* css */`
37707
37748
  outline-color: var(--popover-outline-color);
37708
37749
  outline-offset: 0px;
37709
37750
  box-shadow: var(--popover-box-shadow);
37710
- /* Duration driven by applyNewPosition (visible_rect.js) — 0s (no
37711
- transition) for most repositions (scroll, in particular, needs to
37712
- track its target in lockstep), a real duration only when the
37713
- reposition was itself triggered by a resize. */
37714
- transition-property: left, top;
37715
- transition-duration: var(--popup-position-transition-duration, 0s);
37716
- transition-timing-function: ease-out;
37751
+ /* left/top are NOT transitioned here — applyNewPosition (visible_rect.js)
37752
+ drives that itself via the Web Animations API instead of CSS, so it
37753
+ stays independent from navi-animation's own opacity/scale/display
37754
+ transition list below (no shared transition-property to clobber, no
37755
+ propertyName to filter). */
37717
37756
  overflow: auto;
37718
37757
  overscroll-behavior: none;
37719
37758
 
@@ -37750,11 +37789,12 @@ const css$r = /* css */`
37750
37789
  /* The via-attribute renderer's own default: an element in the top layer
37751
37790
  always uses the initial containing block regardless of "position",
37752
37791
  so this is really "pinned to the viewport" either way — fixed is
37753
- just the more direct way to say so. Overridden back to absolute
37754
- below when genuinely anchored to a real element — see this file's
37755
- top comment for why the two need opposite defaults. The custom
37756
- renderer (no [popover] attribute) never matches either of these
37757
- rules, it's always the base "position: absolute" above. */
37792
+ just the more direct way to say so. Overridden to absolute below only
37793
+ when data-anchor-scrolls is set — see this file's top comment, and
37794
+ that attribute's own JS-side comment in openEffect, for the full
37795
+ fixed-vs-absolute reasoning. The custom renderer (no [popover]
37796
+ attribute) never matches either of these rules, it's always the base
37797
+ "position: absolute" above. */
37758
37798
  &[popover] {
37759
37799
  position: fixed;
37760
37800
  /* The native top layer already gives "last shown wins" for free —
@@ -37763,9 +37803,10 @@ const css$r = /* css */`
37763
37803
  var directly on an ancestor. */
37764
37804
  z-index: unset;
37765
37805
  padding: 0;
37766
- }
37767
- &[popover][data-anchor] {
37768
- position: absolute;
37806
+
37807
+ &[data-anchor-scrolls] {
37808
+ position: absolute;
37809
+ }
37769
37810
  }
37770
37811
 
37771
37812
  &[data-anchor-out-of-view] {
@@ -37899,11 +37940,14 @@ const css$r = /* css */`
37899
37940
  * based on `positionArea`. Any other explicit value is used as-is.
37900
37941
  * @param {string} [props.animationDuration] - Maps to
37901
37942
  * `--popup-animation-duration`.
37902
- * @param {Element|{current: Element}} [props.anchor] - The element the
37943
+ * @param {Element|{current: Element}|string} [props.anchor] - The element the
37903
37944
  * popover is positioned relative to. Defaults to whatever triggered the
37904
37945
  * open (`e.detail.anchor`/`e.detail.source`), if any — with no anchor at
37905
37946
  * all, the popover docks to its container instead (viewport for
37906
- * `layer="top"`, positioned ancestor for `layer="local"`).
37947
+ * `layer="top"`, positioned ancestor for `layer="local"`). A string is
37948
+ * resolved via `document.getElementById` when the popover opens — mainly
37949
+ * useful for `defaultOpen` (there's no triggering event/ref to read a
37950
+ * real element from yet at that point, only an id known up front).
37907
37951
  * @param {"override"|"ignore"} [props.anchorCustomEventDetail="override"] -
37908
37952
  * Whether an explicit `anchor` prop takes precedence over
37909
37953
  * (`"override"`, default) or is ignored in favor of
@@ -38144,11 +38188,14 @@ const usePopoverProps = props => {
38144
38188
  // only has this one call site.
38145
38189
  let anchorElement;
38146
38190
  if (typeof anchor === "string") {
38147
- // A plain string is a near-certain leftover from an older API
38148
- // (anchor used to accept "viewport"/"scrollContainer" directly)
38149
- // anchor is a ref or a DOM element only now; layer/
38150
- // anchorCustomEventDetail cover what those strings used to mean.
38151
- console.warn(`Popover: anchor="${anchor}" is no longer supported — anchor only accepts a ref or a DOM element now. Use layer="local" (was anchor="scrollContainer") or anchorCustomEventDetail="ignore" (was ignoreEventAnchor) instead.`);
38191
+ // Resolved at open time (not render time) via getElementById mainly
38192
+ // for defaultOpen, where there's no triggering event/ref yet to read a
38193
+ // real element from, only an id known up front (e.g. a popover nested
38194
+ // inside another, anchored to an element rendered by that ancestor).
38195
+ anchorElement = document.getElementById(anchor);
38196
+ if (!anchorElement) {
38197
+ console.warn(`Popover: anchor="${anchor}" did not match any element`);
38198
+ }
38152
38199
  } else if (anchor) {
38153
38200
  // anchor prop is a ref or a DOM element — always a real anchor,
38154
38201
  // regardless of anchorCustomEventDetail.
@@ -38157,16 +38204,31 @@ const usePopoverProps = props => {
38157
38204
  anchorElement = e.detail.anchor;
38158
38205
  }
38159
38206
  const hasAnchorElement = Boolean(anchorElement);
38160
- const positionedAncestor = isTopLayer ? null : getPositionedParent(popoverEl);
38207
+ // getPositionedParent already resolves to document.documentElement for
38208
+ // the via-attribute renderer on its own (popoverEl's own `popover`
38209
+ // attribute, present from render regardless of whether it's actually
38210
+ // open yet — see offset_parent.js's own doc) — no isTopLayer branch
38211
+ // needed here.
38212
+ const positionedAncestor = getPositionedParent(popoverEl);
38161
38213
  // Drives the via-attribute renderer's own position: fixed/absolute
38162
38214
  // switch (see this file's top comment) — set here, well before any
38163
38215
  // positioning/measurement runs, so there's no ordering subtlety to get
38164
38216
  // wrong (unlike the CSS this replaced, which keyed off the resolved
38165
38217
  // animation instead, known too late relative to the first measurement).
38166
- if (hasAnchorElement) {
38167
- popoverEl.setAttribute("data-anchor", "");
38218
+ // True only for a real anchor that itself scrolls with the document —
38219
+ // that's the one case `absolute` (scrolling in lockstep with it) is
38220
+ // correct; not just "has an anchor at all", since an anchor that's
38221
+ // itself `position: fixed`, or nested inside something that is (e.g.
38222
+ // anchored to an element inside another top-layer popover/modal dialog,
38223
+ // which are always `position: fixed` — see popover.jsx's/dialog.jsx's
38224
+ // own `[popover]`/`[data-layer="top"]` rules), stays pinned to the
38225
+ // viewport regardless of document scroll too, so this popover must stay
38226
+ // `fixed` right alongside it instead — switching to `absolute` there
38227
+ // would make it drift away from an anchor that never actually moves.
38228
+ if (hasAnchorElement && !findSelfOrAncestorFixedPosition(anchorElement)) {
38229
+ popoverEl.setAttribute("data-anchor-scrolls", "");
38168
38230
  } else {
38169
- popoverEl.removeAttribute("data-anchor");
38231
+ popoverEl.removeAttribute("data-anchor-scrolls");
38170
38232
  }
38171
38233
  if (!isTopLayer) {
38172
38234
  // Assigned fresh on every open (including reopen) — see
@@ -38266,10 +38328,10 @@ const usePopoverProps = props => {
38266
38328
 
38267
38329
  // What we observe for repositioning on resize/scroll/visibility
38268
38330
  // changes: the anchor itself whenever there's a real one (either
38269
- // renderer), otherwise whatever we're docked against instead — the
38270
- // positioned ancestor for the custom renderer, the document for
38271
- // via-attribute.
38272
- const effectiveAnchor = hasAnchorElement ? anchorElement : isTopLayer ? document.documentElement : positionedAncestor;
38331
+ // renderer), otherwise whatever we're docked against instead —
38332
+ // positionedAncestor already is document.documentElement for the
38333
+ // via-attribute renderer (see its own computation above).
38334
+ const effectiveAnchor = hasAnchorElement ? anchorElement : positionedAncestor;
38273
38335
  const positionPopover = positionEvent => {
38274
38336
  let position;
38275
38337
  if (hasAnchorElement) {
@@ -38293,7 +38355,7 @@ const usePopoverProps = props => {
38293
38355
  // real content size, not one already truncated by a stale value.
38294
38356
  popoverEl.style.removeProperty("--container-position-remaining-height");
38295
38357
  popoverEl.style.removeProperty("--container-position-remaining-width");
38296
- position = pickPositionRelativeTo(popoverEl, anchorElement, {
38358
+ const pickOptions = {
38297
38359
  positionArea,
38298
38360
  positionAreaFixed,
38299
38361
  positionAreaWhenAnchorIsInvalid,
@@ -38304,35 +38366,78 @@ const usePopoverProps = props => {
38304
38366
  // real anchor or not — this tells pickPositionRelativeTo to
38305
38367
  // convert the computed coordinates into that ancestor's own
38306
38368
  // local space instead of assuming document-relative absolute
38307
- // (see its own doc in visible_rect.js).
38308
- container: isTopLayer ? undefined : positionedAncestor,
38369
+ // (see its own doc in visible_rect.js). positionedAncestor is
38370
+ // already document.documentElement for the via-attribute
38371
+ // renderer, same as omitting `container` entirely would resolve
38372
+ // to on its own.
38373
+ container: positionedAncestor,
38309
38374
  minLeft,
38310
38375
  event: positionEvent
38311
- });
38376
+ };
38377
+ position = pickPositionRelativeTo(popoverEl, anchorElement, pickOptions);
38312
38378
  position = {
38313
38379
  ...position,
38314
38380
  left: Math.max(position.left, minLeft)
38315
38381
  };
38382
+ applyNewPosition(popoverEl, position);
38383
+ // applyNewPosition above just set --container-position-remaining-
38384
+ // width/height to the real available space near the anchor —
38385
+ // narrower than the "natural" size just measured, whenever there
38386
+ // isn't enough room. If that actually changes the popover's own
38387
+ // rendered box (e.g. its text rewraps once truly constrained), the
38388
+ // position picked above was computed against the wrong (wider,
38389
+ // shorter) box — left uncorrected, it paints one frame too high/low
38390
+ // before ever getting a chance to fix itself, since the
38391
+ // ResizeObserver watching this same element for exactly that
38392
+ // (rectEffect.observeSize below) only reacts on the *next*
38393
+ // animation frame (see visible_rect.js's own observeSize comment
38394
+ // for why: reacting synchronously there would re-trigger the very
38395
+ // observer that just fired, the "ResizeObserver loop" it's built to
38396
+ // avoid). Re-measuring here instead, still without clearing the
38397
+ // constraint this time (unlike above — it's now the real, final
38398
+ // value, not a stale one), converges before this function ever
38399
+ // returns, so nothing gets painted in between.
38400
+ if (popoverEl.offsetWidth !== position.width || popoverEl.offsetHeight !== position.height) {
38401
+ position = pickPositionRelativeTo(popoverEl, anchorElement, pickOptions);
38402
+ position = {
38403
+ ...position,
38404
+ left: Math.max(position.left, minLeft)
38405
+ };
38406
+ applyNewPosition(popoverEl, position);
38407
+ }
38316
38408
  } else {
38317
38409
  // No real anchor: dock against a container instead — omitting
38318
38410
  // pickPositionRelativeTo's own `anchor` argument entirely puts it
38319
38411
  // in its own container-docked mode (see its own doc for what that
38320
- // changes). For the via-attribute renderer, its `container` is
38321
- // left unspecified too pickPositionRelativeTo auto-resolves it
38322
- // to the viewport on its own, since popoverEl's own [popover]
38323
- // attribute signals that (see getPositioningContainer). For the
38324
- // custom renderer, its own positioned ancestor is passed
38325
- // explicitly instead, since it's already computed above for
38326
- // visibleRectEffect's own observation target.
38327
- position = pickPositionRelativeTo(popoverEl, null, {
38412
+ // changes). positionedAncestor is passed either way already
38413
+ // document.documentElement for the via-attribute renderer (see its
38414
+ // own computation above), the real positioned ancestor for the
38415
+ // custom renderer, already computed above for visibleRectEffect's
38416
+ // own observation target regardless.
38417
+ const pickOptions = {
38328
38418
  positionArea,
38329
- container: isTopLayer ? undefined : positionedAncestor,
38419
+ container: positionedAncestor,
38330
38420
  marginWithContainer: resolveSpacingSize(marginWithContainer),
38331
38421
  event: positionEvent
38332
- });
38422
+ };
38423
+ position = pickPositionRelativeTo(popoverEl, null, pickOptions);
38424
+ applyNewPosition(popoverEl, position);
38425
+ // Same reasoning as the real-anchor branch above — a first open
38426
+ // starts from the same unconstrained state (no
38427
+ // --container-position-remaining-width/height set yet either),
38428
+ // so the same rewrap-after-constraint mismatch can happen here too.
38429
+ if (popoverEl.offsetWidth !== position.width || popoverEl.offsetHeight !== position.height) {
38430
+ position = pickPositionRelativeTo(popoverEl, null, pickOptions);
38431
+ applyNewPosition(popoverEl, position);
38432
+ }
38333
38433
  }
38334
38434
  debugPopup(positionEvent, `positionPopover() -> left: ${position.left}, top: ${position.top}`);
38335
- applyNewPosition(popoverEl, position);
38435
+ // A descendant's own visibleRectEffect (visible_rect.js — e.g. a
38436
+ // Callout, or another nested Popover, anchored to something inside
38437
+ // this one) knowing to recheck its own position whenever this popover
38438
+ // itself moves is handled generically by applyNewPosition itself
38439
+ // (dispatches navi_position_change on every call) — nothing to do
38440
+ // here.
38336
38441
  };
38337
38442
  if (scrollCapture) {
38338
38443
  addCleanup(trapScrollInside(popoverEl));
@@ -38342,6 +38447,32 @@ const usePopoverProps = props => {
38342
38447
  debug: debugFocus
38343
38448
  }));
38344
38449
  }
38450
+
38451
+ // Real top-layer stacking is call-order-based (see this file's own
38452
+ // "the top layer stacks later showPopover() calls above earlier ones"
38453
+ // comment) — so when a popover nests inside another openable ancestor
38454
+ // (a <dialog>, another popover, a <details>, or an [aria-expanded]
38455
+ // container) and that ancestor *closes and reopens* while we ourselves
38456
+ // stay open the whole time, the ancestor's own fresh showModal()/
38457
+ // showPopover() call ends up later than our own stale one, leaving us
38458
+ // stacked underneath it — even though we never actually closed (an
38459
+ // ancestor closing hides us for free via the normal "display: none
38460
+ // cascades through the top layer too" rule, no explicit hidePopover()
38461
+ // needed for that half). Deliberately does NOT go through
38462
+ // openController/requestClose — we're not being asked to close, only to
38463
+ // re-assert our own stacking position once the ancestor comes back.
38464
+ if (isTopLayer) {
38465
+ onAncestorReopen(popoverEl, () => {
38466
+ if (backdropEl?.matches(":popover-open")) {
38467
+ backdropEl.hidePopover();
38468
+ }
38469
+ backdropEl?.showPopover();
38470
+ if (popoverEl.matches(":popover-open")) {
38471
+ popoverEl.hidePopover();
38472
+ }
38473
+ popoverEl.showPopover();
38474
+ });
38475
+ }
38345
38476
  const rectEffect = visibleRectEffect(effectiveAnchor, ({
38346
38477
  visibilityRatio
38347
38478
  }, {
@@ -38369,6 +38500,14 @@ const usePopoverProps = props => {
38369
38500
  // while open (e.g. an expand/collapse toggle inside it) — not just when
38370
38501
  // the anchor itself moves/resizes/re-anchors.
38371
38502
  rectEffect.observeSize(popoverEl);
38503
+ // A descendant anchored to something inside this popover (a Callout, a
38504
+ // further-nested Popover) needing to know about this popover's own
38505
+ // left/top repositioning transition — not just that the target changed
38506
+ // (navi_position_change above), but that a real, currently-playing
38507
+ // transition is moving it right now — is handled generically by
38508
+ // applyNewPosition itself (see its own notifyPositionTransition in
38509
+ // visible_rect.js), since positionPopover already goes through it
38510
+ // above; nothing to wire up here.
38372
38511
  addCleanup(() => {
38373
38512
  rectEffect.disconnect();
38374
38513
  });
@@ -39810,10 +39949,75 @@ const TimeTime = ({
39810
39949
  });
39811
39950
  }
39812
39951
  const totalMinutes = date.getHours() * 60 + date.getMinutes();
39813
- const text = formatMinuteDuration(totalMinutes, {
39814
- lang,
39815
- format
39816
- });
39952
+ // Midnight (hour 0) can't go through formatMinuteDuration's own default
39953
+ // zero-hour handling: it drops a zero-valued unit entirely (by design — a
39954
+ // real 5-minute duration should print as "5 minutes", not "0 hours 5
39955
+ // minutes"), so "00:05" would otherwise render identically to an actual
39956
+ // 5-minute duration, silently losing the fact that it's midnight. Every
39957
+ // other hour keeps at least its own "N hour(s)" wording as a hint that
39958
+ // this is a time-of-day, not a duration — only hour 0 loses that hint
39959
+ // entirely.
39960
+ let text;
39961
+ if (date.getHours() !== 0) {
39962
+ text = formatMinuteDuration(totalMinutes, {
39963
+ lang,
39964
+ format
39965
+ });
39966
+ } else if (format !== "long") {
39967
+ // short/narrow/compact: keep the "0 h"/"0h" hour part instead of
39968
+ // dropping it (formatMinuteDuration's own alwaysShowHours) — e.g.
39969
+ // "0 h et 5 min"/"0h 5min"/"0h05" — rather than substituting a
39970
+ // translated "midnight" word, which would look out of place squeezed
39971
+ // into these otherwise terse, symbol-based formats.
39972
+ text = formatMinuteDuration(totalMinutes, {
39973
+ lang,
39974
+ format,
39975
+ alwaysShowHours: true
39976
+ });
39977
+ } else {
39978
+ const midnightWord = naviI18n("time.midnight", undefined, {
39979
+ lang
39980
+ });
39981
+ if (midnightWord === "time.midnight") {
39982
+ // No "midnight" translation registered for this language — fall back
39983
+ // to this language's own literal "0 heure(s)" wording instead (still
39984
+ // better than leaking the untranslated key, or substituting an
39985
+ // English word that wouldn't grammatically match the rest of the
39986
+ // sentence in whatever language this actually is).
39987
+ text = formatMinuteDuration(totalMinutes, {
39988
+ lang,
39989
+ format,
39990
+ alwaysShowHours: true
39991
+ });
39992
+ } else {
39993
+ // Swap just the "0 heure(s)" part of the Intl-generated duration
39994
+ // string for the translated "midnight" word, keeping everything else
39995
+ // (the conjunction, the minutes part) exactly as Intl would produce
39996
+ // for this locale — formatToParts tags each token with the unit it
39997
+ // belongs to, so the swap doesn't need to know the locale's own
39998
+ // grammar/word order. Only ever one hour-tagged group per call
39999
+ // (hours is always 0 or absent here), but guarded anyway in case a
40000
+ // future Intl implementation ever splits it into more parts.
40001
+ const parts = new Intl.DurationFormat(lang, {
40002
+ style: "long",
40003
+ hoursDisplay: "always"
40004
+ }).formatToParts({
40005
+ hours: 0,
40006
+ minutes: date.getMinutes()
40007
+ });
40008
+ let hourGroupReplaced = false;
40009
+ text = parts.map(part => {
40010
+ if (part.unit !== "hour") {
40011
+ return part.value;
40012
+ }
40013
+ if (hourGroupReplaced) {
40014
+ return "";
40015
+ }
40016
+ hourGroupReplaced = true;
40017
+ return midnightWord;
40018
+ }).join("");
40019
+ }
40020
+ }
39817
40021
  return jsx(TimeText, {
39818
40022
  dateTime: dateTime,
39819
40023
  ...props,