@jsenv/navi 0.29.236 → 0.29.298

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.
@@ -6984,7 +6984,17 @@ const openCallout = (
6984
6984
  `append callout into ${getElementSignature(calloutContainer)}`,
6985
6985
  );
6986
6986
  }
6987
+ let checkCalloutInsertionImpact;
6988
+ {
6989
+ checkCalloutInsertionImpact = startCalloutInsertionImpactCheck(
6990
+ calloutContainer,
6991
+ anchorElement,
6992
+ );
6993
+ }
6987
6994
  calloutContainer.appendChild(calloutElement);
6995
+ if (checkCalloutInsertionImpact) {
6996
+ checkCalloutInsertionImpact();
6997
+ }
6988
6998
  calloutElement.showPopover();
6989
6999
  addTeardown(() => {
6990
7000
  calloutElement.remove();
@@ -7550,6 +7560,54 @@ const canContainCallout = (element) => {
7550
7560
  return true;
7551
7561
  };
7552
7562
 
7563
+ // A callout is a popover with no layout box of its own, so putting it in the
7564
+ // tree must move nothing. Anything that does move means an app rule counting
7565
+ // element children — :last-child, :nth-child, :only-child, :empty, > * + * —
7566
+ // took the callout for one of them and changed subject. Invisible from the
7567
+ // app, where the rule and the markup both read fine.
7568
+ // Two boxes rather than every child of the container: a callout on a list row
7569
+ // would otherwise measure the whole list on each press.
7570
+ const startCalloutInsertionImpactCheck = (calloutContainer, anchorElement) => {
7571
+ if (!anchorElement) {
7572
+ return null;
7573
+ }
7574
+ const anchorBoxBefore = anchorElement.getBoundingClientRect();
7575
+ const containerBoxBefore = calloutContainer.getBoundingClientRect();
7576
+ return () => {
7577
+ const anchorBoxAfter = anchorElement.getBoundingClientRect();
7578
+ const containerBoxAfter = calloutContainer.getBoundingClientRect();
7579
+ let what;
7580
+ let boxBefore;
7581
+ let boxAfter;
7582
+ if (!isSameBox(anchorBoxBefore, anchorBoxAfter)) {
7583
+ what = "moved it";
7584
+ boxBefore = anchorBoxBefore;
7585
+ boxAfter = anchorBoxAfter;
7586
+ } else if (!isSameBox(containerBoxBefore, containerBoxAfter)) {
7587
+ what = `moved ${getElementSignature(calloutContainer)} around it`;
7588
+ boxBefore = containerBoxBefore;
7589
+ boxAfter = containerBoxAfter;
7590
+ } else {
7591
+ return;
7592
+ }
7593
+ console.warn(
7594
+ `opening a callout on ${getElementSignature(anchorElement)} ${what}: ${formatBox(boxBefore)} -> ${formatBox(boxAfter)}.
7595
+ A callout draws nothing in the flow, but while it is open it is an element child of ${getElementSignature(calloutContainer)}: a rule counting children there (:last-child, :nth-child, :only-child, :empty, > * + *) counts it too and matches something else than what it aims at.
7596
+ Say which children the rule counts: :nth-last-child(1 of S) / :nth-child(An+B of S), where S names yours (a class, or :not([navi-out-of-flow]) — the marker navi's out-of-flow elements carry). A sibling rule takes the marker between its compounds: > *:not([navi-out-of-flow]) + *:not([navi-out-of-flow]).
7597
+ See @jsenv/navi docs/css_architecture.md.`,
7598
+ );
7599
+ };
7600
+ };
7601
+ const isSameBox = (a, b) => {
7602
+ return (
7603
+ a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
7604
+ );
7605
+ };
7606
+ const formatBox = ({ x, y, width, height }) => {
7607
+ const round = (value) => Math.round(value * 100) / 100;
7608
+ return `${round(width)}x${round(height)} at ${round(x)},${round(y)}`;
7609
+ };
7610
+
7553
7611
  const escapeHtml = (string) => {
7554
7612
  return string
7555
7613
  .replace(/&/g, "&")
@@ -18950,6 +19008,20 @@ const resolveSpacingSize = (size, element, property = "padding") => {
18950
19008
  }
18951
19009
  return normalizeStyle(SIZE_MAP[size] || size, property, "js", element);
18952
19010
  };
19011
+ // Whether a spacing is nothing at all, answered without resolving it. A
19012
+ // viewport or container length resolves through a computed-style read, and
19013
+ // asked during a render that read forces the browser to compute the style of
19014
+ // a tree still being built, stylesheets still being adopted. Zero is a fact
19015
+ // about the number written, whatever the unit; a scale keyword is never zero.
19016
+ const isZeroSpacingSize = (size) => {
19017
+ if (typeof size === "number") {
19018
+ return size === 0;
19019
+ }
19020
+ if (typeof size !== "string") {
19021
+ return false;
19022
+ }
19023
+ return parseFloat(size) === 0;
19024
+ };
18953
19025
 
18954
19026
  const COLOR_KEYWORD_MAP = {
18955
19027
  primary: "var(--navi-color-primary)",
@@ -20605,16 +20677,16 @@ const useComposeElementRef = (syncElement, externalRef) => {
20605
20677
  const cleanupRef = useRef(null);
20606
20678
  const elRef = useRef(null);
20607
20679
  const prevSyncElementRef = useRef(undefined);
20608
- const stableRef = useRef(null);
20680
+ const holderRef = useRef(null);
20609
20681
  const externalRefRef = useRef(externalRef);
20610
20682
  const syncElementRef = useRef(syncElement);
20611
20683
  syncElementRef.current = syncElement;
20612
- // Detect external ref identity change between renders. The refCallback is
20613
- // stable across renders, so when the parent passes a new ref object (or
20614
- // switches from null to a ref), Preact does NOT re-fire the callback while
20615
- // the DOM element is unchanged. We must manually clear the old ref and
20616
- // populate the new one with the current element to avoid leaving the new
20617
- // ref's `.current` stuck at `null`.
20684
+ // Detect external ref identity change between renders. The refCallback keeps
20685
+ // its identity once it holds the element, so when the parent passes a new
20686
+ // ref object (or switches from null to a ref), Preact does NOT re-fire the
20687
+ // callback while the DOM element is unchanged. We must manually clear the
20688
+ // old ref and populate the new one with the current element to avoid
20689
+ // leaving the new ref's `.current` stuck at `null`.
20618
20690
  const prevExternalRefRef = useRef(externalRef);
20619
20691
  if (prevExternalRefRef.current !== externalRef) {
20620
20692
  const previous = prevExternalRefRef.current;
@@ -20632,12 +20704,12 @@ const useComposeElementRef = (syncElement, externalRef) => {
20632
20704
  }
20633
20705
  externalRefRef.current = externalRef;
20634
20706
 
20635
- if (!stableRef.current) {
20636
- // Created once, like the ref callback that calls it, and reading the sync
20637
- // function through a ref for that reason: the element can be replaced long
20638
- // after the first render — a tag that changes, a box hidden then shown —
20639
- // and what the new element gets must be the current render's sync, not
20640
- // the one the first render closed over.
20707
+ if (!holderRef.current) {
20708
+ // Created once, and reading the sync function through a ref for that
20709
+ // reason: the element can be replaced long after the first render — a tag
20710
+ // that changes, a box hidden then shown — and what the new element gets
20711
+ // must be the current render's sync, not the one the first render closed
20712
+ // over.
20641
20713
  const runSync = (el) => {
20642
20714
  if (cleanupRef.current) {
20643
20715
  cleanupRef.current();
@@ -20650,6 +20722,18 @@ const useComposeElementRef = (syncElement, externalRef) => {
20650
20722
  cleanupRef.current = cleanup;
20651
20723
  }
20652
20724
  };
20725
+ holderRef.current = { runSync, refCallback: null };
20726
+ }
20727
+ const { runSync } = holderRef.current;
20728
+
20729
+ // A new callback on every render until Preact has handed it the element,
20730
+ // the same one afterwards. Preact re-applies a ref only when its identity
20731
+ // changes, and a diff that throws with no error boundary above skips
20732
+ // commitRoot: its refs are dropped while its DOM stays and is adopted as
20733
+ // mounted by the next render. A callback that never fired would then never
20734
+ // fire, and a Box would stay raw HTML for the life of the document. Free on
20735
+ // a healthy mount, where the first callback is the only one ever created.
20736
+ if (!elRef.current) {
20653
20737
  const refCallback = (el) => {
20654
20738
  elRef.current = el;
20655
20739
  // Keep .current in sync immediately so useEffect callbacks that read
@@ -20673,9 +20757,9 @@ const useComposeElementRef = (syncElement, externalRef) => {
20673
20757
  prevSyncElementRef.current = undefined;
20674
20758
  }
20675
20759
  };
20676
- stableRef.current = { refCallback, runSync };
20760
+ holderRef.current.refCallback = refCallback;
20677
20761
  }
20678
- const { refCallback, runSync } = stableRef.current;
20762
+ const { refCallback } = holderRef.current;
20679
20763
 
20680
20764
  // If element already mounted, re-sync when syncElement reference changed.
20681
20765
  if (elRef.current && syncElement !== prevSyncElementRef.current) {
@@ -32186,6 +32270,17 @@ registerNaviCommand("--navi-send", (source, event, { requester }) => {
32186
32270
  return sent;
32187
32271
  }
32188
32272
  if (isRunning) {
32273
+ // An optimistic control takes its send as done on its own word: what
32274
+ // follows it runs at once and the run goes on detached — still
32275
+ // watched, still able to fail, its error callout then drawn on what
32276
+ // surrounds the closed surface it was typed in (see openCallout's
32277
+ // anchor resolution in callout.js).
32278
+ const sendController = (findControlHost(target) || target)
32279
+ .__uiStateController__;
32280
+ if (sendController?.optimistic) {
32281
+ runAfterSend();
32282
+ return sent;
32283
+ }
32189
32284
  // The send is committing but has not finished: leaving now would take
32190
32285
  // the form off the screen mid-submission (a popup closing over its own
32191
32286
  // running action, a slide moving on before it is answered). What
@@ -36777,6 +36872,16 @@ const useControlProps = (props, {
36777
36872
  if (!deferredCommand) {
36778
36873
  return;
36779
36874
  }
36875
+ if (uiStateController.optimistic) {
36876
+ // An optimistic button takes its action as done on its own word:
36877
+ // the command runs at once and the run goes on detached, the same
36878
+ // release the form counterpart gives the send (see --navi-send in
36879
+ // commands.js). Only a refused press has nothing to follow.
36880
+ if (completion.result !== false) {
36881
+ deferredCommand();
36882
+ }
36883
+ return;
36884
+ }
36780
36885
  runWhenActionSucceeded(completion, deferredCommand);
36781
36886
  };
36782
36887
  return {
@@ -39989,47 +40094,35 @@ const LoadingIndicatorFluid = ({
39989
40094
  const [containerWidth, setContainerWidth] = useState(0);
39990
40095
  const [containerHeight, setContainerHeight] = useState(0);
39991
40096
  const [containerRadius, setContainerRadius] = useState(0);
40097
+
40098
+ // Sized by the observer alone, first frame included: an element gets an
40099
+ // observation for the size it has when first observed, delivered after the
40100
+ // frame's own layout and before its paint. Measuring here instead would
40101
+ // force a layout in the middle of the commit, on a tree the commit is still
40102
+ // writing to, and the frame would lay it out again. Inside the callback style
40103
+ // and layout are clean, so the radius read there is a lookup, not a recalc.
39992
40104
  useLayoutEffect(() => {
39993
40105
  const indicatorEl = ref.current;
39994
40106
  if (!indicatorEl) {
39995
40107
  return null;
39996
40108
  }
39997
- const {
39998
- width,
39999
- height
40000
- } = indicatorEl.getBoundingClientRect();
40001
- setContainerWidth(width);
40002
- setContainerHeight(height);
40003
- if (radius === undefined || radius === "inherit") {
40004
- const parentEl = indicatorEl.parentElement;
40005
- // Prefer the inline style (always available, even before layout is computed).
40006
- // Fall back to computed longhands — getComputedStyle shorthand may return ""
40007
- // if the browser hasn't resolved the layout yet.
40008
- const radius = parentEl.style.borderRadius || parentEl.style.borderTopLeftRadius || getComputedStyle(parentEl).borderTopLeftRadius || "0px";
40009
- setContainerRadius(radius);
40010
- }
40011
- let animationFrameId = null;
40012
- // Create a resize observer to detect changes in the container's dimensions
40013
- const resizeObserver = new ResizeObserver(entries => {
40014
- // Use requestAnimationFrame to debounce updates
40015
- if (animationFrameId) {
40016
- cancelAnimationFrame(animationFrameId);
40017
- }
40018
- animationFrameId = requestAnimationFrame(() => {
40019
- const [containerEntry] = entries;
40020
- const {
40021
- width,
40022
- height
40023
- } = containerEntry.contentRect;
40024
- setContainerWidth(width);
40025
- setContainerHeight(height);
40026
- });
40109
+ const resizeObserver = new ResizeObserver(([containerEntry]) => {
40110
+ const {
40111
+ width,
40112
+ height
40113
+ } = containerEntry.contentRect;
40114
+ setContainerWidth(width);
40115
+ setContainerHeight(height);
40116
+ if (radius === undefined || radius === "inherit") {
40117
+ const parentEl = indicatorEl.parentElement;
40118
+ // The inline style says the radius as the caller wrote it; the
40119
+ // computed longhand otherwise, the shorthand computing to "" when the
40120
+ // four corners differ.
40121
+ setContainerRadius(parentEl.style.borderRadius || parentEl.style.borderTopLeftRadius || getComputedStyle(parentEl).borderTopLeftRadius || "0px");
40122
+ }
40027
40123
  });
40028
40124
  resizeObserver.observe(indicatorEl);
40029
40125
  return () => {
40030
- if (animationFrameId) {
40031
- cancelAnimationFrame(animationFrameId);
40032
- }
40033
40126
  resizeObserver.disconnect();
40034
40127
  };
40035
40128
  }, []);
@@ -44371,6 +44464,13 @@ const measureDrawing = ({
44371
44464
  * Measured rather than declared: a bar's size comes from a prop, a theme
44372
44465
  * variable, its own content or the device's notch, and only the used value
44373
44466
  * knows all four.
44467
+ *
44468
+ * Written once per task, after every bar has measured. The variable resizes
44469
+ * an ancestor of the bars — the scroll container takes its padding from it —
44470
+ * so a write landing between two measurements turns the second one into a
44471
+ * style recalc and a layout of the whole page. Sizes are queued, and the queue
44472
+ * lands in a microtask: still before the browser paints, so the content is
44473
+ * never seen under a bar, and after every read of the commit that queued them.
44374
44474
  */
44375
44475
 
44376
44476
  // Several bars can share an edge — during a page transition the outgoing and
@@ -44384,10 +44484,14 @@ const sizeMapByArea = new Map();
44384
44484
  // size again — the ones for the smaller bars, and the second half of a page
44385
44485
  // transition.
44386
44486
  const writtenValueByArea = new Map();
44487
+ // Sizes said and not yet written. One queue for both callers below: a bar
44488
+ // saying its size twice before the write keeps the last word, whoever said it.
44489
+ const pendingSizeMapByArea = new Map();
44490
+ let flushMicrotaskQueued = false;
44491
+ let flushFrame = null;
44387
44492
 
44388
- // A size that must land before the next paint: a render changed the bar, so
44389
- // the room it takes is given back in that same commit and the content is never
44390
- // painted under it.
44493
+ // A size a render produced: a prop, the children or a theme variable changed
44494
+ // the bar, and the room it takes is given back before that commit is painted.
44391
44495
  /**
44392
44496
  * @param {"top"|"bottom"|"left"|"right"} area
44393
44497
  * @param {Element} barElement - Which bar this size belongs to.
@@ -44395,41 +44499,47 @@ const writtenValueByArea = new Map();
44395
44499
  * content.
44396
44500
  */
44397
44501
  const setFixedBarSpace = (area, barElement, size) => {
44398
- dropPendingSize(area, barElement);
44399
- storeSize(area, barElement, size);
44400
- writeSpace(area);
44401
- };
44402
-
44403
- // A size nothing asked for, coming from a ResizeObserver. Writing the variable
44404
- // resizes an ANCESTOR of the bars — the scroll container takes its padding
44405
- // from it — and mutating layout from inside a resize callback is what makes
44406
- // the browser report "ResizeObserver loop completed with undelivered
44407
- // notifications". So the write waits for the frame that resize produced.
44408
- // Queued here rather than deferred by each bar on its own, so the bars sharing
44409
- // an edge resolve to a single write instead of one per bar.
44502
+ queueSize(area, barElement, size);
44503
+ if (flushMicrotaskQueued) {
44504
+ return;
44505
+ }
44506
+ flushMicrotaskQueued = true;
44507
+ queueMicrotask(() => {
44508
+ flushMicrotaskQueued = false;
44509
+ flushPendingSizes();
44510
+ });
44511
+ };
44512
+
44513
+ // A size nothing asked for, coming from a ResizeObserver. Mutating layout from
44514
+ // inside a resize callback is what makes the browser report "ResizeObserver
44515
+ // loop completed with undelivered notifications", so this write waits for the
44516
+ // frame that resize produced.
44410
44517
  /**
44411
44518
  * @param {"top"|"bottom"|"left"|"right"} area
44412
44519
  * @param {Element} barElement - Which bar this size belongs to.
44413
44520
  * @param {number} size - In px.
44414
44521
  */
44415
44522
  const requestFixedBarSpace = (area, barElement, size) => {
44523
+ queueSize(area, barElement, size);
44524
+ if (flushFrame !== null) {
44525
+ return;
44526
+ }
44527
+ flushFrame = requestAnimationFrame(() => {
44528
+ flushFrame = null;
44529
+ flushPendingSizes();
44530
+ });
44531
+ };
44532
+
44533
+ const queueSize = (area, barElement, size) => {
44416
44534
  let pendingSizeMap = pendingSizeMapByArea.get(area);
44417
44535
  if (!pendingSizeMap) {
44418
44536
  pendingSizeMap = new Map();
44419
44537
  pendingSizeMapByArea.set(area, pendingSizeMap);
44420
44538
  }
44421
44539
  pendingSizeMap.set(barElement, size);
44422
- if (flushFrame !== null) {
44423
- return;
44424
- }
44425
- flushFrame = requestAnimationFrame(flushPendingSizes);
44426
44540
  };
44427
44541
 
44428
- const pendingSizeMapByArea = new Map();
44429
- let flushFrame = null;
44430
-
44431
44542
  const flushPendingSizes = () => {
44432
- flushFrame = null;
44433
44543
  for (const [area, pendingSizeMap] of pendingSizeMapByArea) {
44434
44544
  for (const [barElement, size] of pendingSizeMap) {
44435
44545
  storeSize(area, barElement, size);
@@ -44439,20 +44549,6 @@ const flushPendingSizes = () => {
44439
44549
  pendingSizeMapByArea.clear();
44440
44550
  };
44441
44551
 
44442
- // What the bar itself just said wins over what its observer had queued about
44443
- // it: a bar unmounting gives its room back, and a size queued for it before
44444
- // that must not put it back.
44445
- const dropPendingSize = (area, barElement) => {
44446
- const pendingSizeMap = pendingSizeMapByArea.get(area);
44447
- if (!pendingSizeMap) {
44448
- return;
44449
- }
44450
- pendingSizeMap.delete(barElement);
44451
- if (pendingSizeMap.size === 0) {
44452
- pendingSizeMapByArea.delete(area);
44453
- }
44454
- };
44455
-
44456
44552
  const storeSize = (area, barElement, size) => {
44457
44553
  let sizeMap = sizeMapByArea.get(area);
44458
44554
  if (!sizeMap) {
@@ -48732,6 +48828,7 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48732
48828
  * replace?: boolean,
48733
48829
  * actionStandalone?: boolean,
48734
48830
  * actionAbortable?: boolean,
48831
+ * optimistic?: boolean,
48735
48832
  * [key: string]: any,
48736
48833
  * }>}
48737
48834
  * @param {boolean} [replace] Go where the press leads — an `href`, a
@@ -48775,6 +48872,12 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
48775
48872
  * running, which the app watches from somewhere else; never for one holding
48776
48873
  * an answer the screen is the only place to read (see
48777
48874
  * docs/interactions.md#the-fourth-question-whose-wait-is-it).
48875
+ * @param {boolean} [optimistic] The action is taken as done on the button's own
48876
+ * word: no busy state, a second press queues behind the run, and its
48877
+ * `command` runs at once — the popup it sits in closes over the run, which
48878
+ * goes on behind. A failure then has no popup to be read in: `resetOnError`
48879
+ * puts the button back and the error callout is drawn on what surrounds the
48880
+ * closed popup (see docs/popup_open.md#the-popup-owns-its-open-state).
48778
48881
  * @param {boolean} [actionAbortable] The person waiting may give up on this
48779
48882
  * button's action: closing the popup the run holds calls it off and goes
48780
48883
  * through, instead of being refused. For a run whose answer may never come —
@@ -59662,7 +59765,7 @@ const useDialogProps = props => {
59662
59765
  bottom: false,
59663
59766
  left: false
59664
59767
  };
59665
- if (resolveSpacingSize(marginWithContainer) === 0) {
59768
+ if (isZeroSpacingSize(marginWithContainer)) {
59666
59769
  const {
59667
59770
  y,
59668
59771
  x