@jsenv/navi 0.29.418 → 0.29.419

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/dev/jsenv_navi.js +192 -43
  3. package/dist/dev/jsenv_navi.js.map +10 -10
  4. package/dist/jsenv_navi.js +190 -41
  5. package/dist/jsenv_navi.js.map +10 -10
  6. package/docs/AI_INSTRUCTIONS.md +177 -261
  7. package/docs/actions.md +164 -251
  8. package/docs/autofocus.md +66 -83
  9. package/docs/control_object.md +117 -214
  10. package/docs/control_value.md +121 -185
  11. package/docs/create_and_edit.md +159 -254
  12. package/docs/css_architecture.md +196 -260
  13. package/docs/data_states.md +41 -44
  14. package/docs/dialog_shape.md +91 -134
  15. package/docs/drag_interactions.md +115 -174
  16. package/docs/drag_to_travel.md +10 -9
  17. package/docs/dynamic_import.md +119 -136
  18. package/docs/error_handling.md +75 -127
  19. package/docs/field_validation.md +49 -89
  20. package/docs/form_changed.md +61 -78
  21. package/docs/group.md +141 -0
  22. package/docs/i18n.md +31 -76
  23. package/docs/interactions.md +6 -4
  24. package/docs/list_action.md +36 -51
  25. package/docs/list_refresh.md +73 -115
  26. package/docs/mobile_touch.md +56 -62
  27. package/docs/navigation.md +377 -723
  28. package/docs/network_policy.md +61 -75
  29. package/docs/pan_zoom.md +62 -103
  30. package/docs/popup_backdrop.md +128 -168
  31. package/docs/popup_lift.md +179 -268
  32. package/docs/popup_open.md +380 -651
  33. package/docs/resource.md +228 -408
  34. package/docs/route_transitions.md +333 -482
  35. package/docs/safe_area.md +102 -89
  36. package/docs/scroll.md +175 -335
  37. package/docs/state_binding.md +2 -1
  38. package/docs/testid.md +18 -23
  39. package/docs/typography.md +111 -141
  40. package/docs/view_transitions.md +106 -104
  41. package/docs/z_index.md +61 -109
  42. package/package.json +2 -2
  43. package/docs/badge_list.md +0 -83
  44. package/docs/control_group.md +0 -168
  45. package/docs/mobile_layout_pitfalls.md +0 -145
@@ -14476,7 +14476,11 @@ const createOpenController = (
14476
14476
  const applyChange = () => {
14477
14477
  // Recorded with the change itself: what the DOM shows is what a render
14478
14478
  // landing between the ask and the picture must draw (see `openedInDom`).
14479
- controller.openedInDom = opened;
14479
+ // An opening records it inside the change, once its content is built
14480
+ // (see open() below).
14481
+ if (!opened) {
14482
+ controller.openedInDom = false;
14483
+ }
14480
14484
  change();
14481
14485
  };
14482
14486
  const { transitionChange } = controller;
@@ -14756,15 +14760,17 @@ const createOpenController = (
14756
14760
  controller.mountContent?.();
14757
14761
  // Only now — after the content has been built, before openEffect
14758
14762
  // shows it. Dialog/Popover recompute aria-expanded and navi-hidden
14759
- // from this flag on every render, and mountContent above renders
14763
+ // from `openedInDom` on every render, and mountContent above renders
14760
14764
  // synchronously: flipping it any earlier commits an already-open DOM
14761
14765
  // (aria-expanded "true", navi-hidden gone) before openEffect has run
14762
14766
  // a single statement, so the "closed" frame it pins to transition
14763
14767
  // from is in fact the open one and the entrance animation has
14764
- // nothing to play. It also gives the content it just built the
14765
- // opening it is documented to observe — mounted while the popup
14766
- // reads as closed, told it opened right after (see
14767
- // popup_content_mount.js and use_displayed_layout_effect.js).
14768
+ // nothing to play — on every first opening that builds its content.
14769
+ // It also gives the content it just built the opening it is
14770
+ // documented to observe — mounted while the popup reads as closed,
14771
+ // told it opened right after (see popup_content_mount.js and
14772
+ // use_displayed_layout_effect.js).
14773
+ controller.openedInDom = true;
14768
14774
  controller.opened = true;
14769
14775
  // Which press it opened during, so the release of that press is not
14770
14776
  // read as somebody dismissing it (see openedDuringThisPress).
@@ -15155,7 +15161,14 @@ const useOpenPropsEffectOnOpenController = (
15155
15161
  lastRunOpenRef.current = open;
15156
15162
 
15157
15163
  if (isFirstRun) {
15158
- const mountOpenReason = open || defaultOpen;
15164
+ // "interaction" is said even over what the caller holds reading open: a
15165
+ // popup built by the request that opens it — a dialog standing in until
15166
+ // its signal said open, see DialogStandIn in dialog.jsx — mounts BECAUSE
15167
+ // of that opening.
15168
+ const mountOpenReason =
15169
+ open && defaultOpen === "interaction"
15170
+ ? "interaction"
15171
+ : open || defaultOpen;
15159
15172
  if (mountOpenReason) {
15160
15173
  // Whether this popup being open is something that just happened, or
15161
15174
  // something that was already true when the page appeared. "interaction"
@@ -27804,6 +27817,18 @@ const useAsyncData = (promiseOrAction, {
27804
27817
 
27805
27818
  // ─── useAction ────────────────────────────────────────────────────────────────
27806
27819
 
27820
+ // subscribe() calls back at once with the value the signal holds; what these
27821
+ // subscriptions are for is what changes after.
27822
+ const subscribeToChanges = (signal, onChange) => {
27823
+ let initial = true;
27824
+ return signal.subscribe(value => {
27825
+ if (initial) {
27826
+ initial = false;
27827
+ return;
27828
+ }
27829
+ onChange(value);
27830
+ });
27831
+ };
27807
27832
  const LoadingContext = createContext(null);
27808
27833
  const actionPendingPromiseWeakMap = new WeakMap();
27809
27834
  const dismissedActionWeakSet = new WeakSet();
@@ -27848,6 +27873,15 @@ const useActionAsyncData = (action, {
27848
27873
  const runningState = action.runningStateSignal.peek();
27849
27874
  const [, setTick] = useState(0);
27850
27875
  const routePage = useContext(RoutePageContext);
27876
+ // What this render read of the action, for the subscriptions below to tell
27877
+ // a change that came between the render and them from none at all.
27878
+ const readRef = useRef(null);
27879
+ readRef.current = {
27880
+ runningState,
27881
+ data: action.dataSignal.peek(),
27882
+ params: action.paramsSignal.peek(),
27883
+ settling: action.paramsSettlingSignal.peek()
27884
+ };
27851
27885
  useEffect(() => {
27852
27886
  let stopOwingRender = null;
27853
27887
  const rerender = () => {
@@ -27875,7 +27909,12 @@ const useActionAsyncData = (action, {
27875
27909
  }
27876
27910
  setTick(n => n + 1);
27877
27911
  };
27878
- const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
27912
+ // A run under way is a new try: an error dismissed before it is not the
27913
+ // one it may end on.
27914
+ if (action.runningStateSignal.peek() === RUNNING) {
27915
+ dismissedActionWeakSet.delete(action);
27916
+ }
27917
+ const unsubscribeFromRunningState = subscribeToChanges(action.runningStateSignal, state => {
27879
27918
  if (state === RUNNING) {
27880
27919
  dismissedActionWeakSet.delete(action);
27881
27920
  }
@@ -27886,38 +27925,24 @@ const useActionAsyncData = (action, {
27886
27925
  // (a PUT upserting an item that a GET_MANY list already holds) changes the
27887
27926
  // data while this action stays COMPLETED. Subscribing here re-renders
27888
27927
  // through the same controlled path as the run state, instead of `.value`.
27889
- let dataNotificationIsInitial = true;
27890
- const unsubscribeFromData = action.dataSignal.subscribe(() => {
27891
- if (dataNotificationIsInitial) {
27892
- // subscribe() calls back synchronously with the current value
27893
- dataNotificationIsInitial = false;
27894
- return;
27895
- }
27896
- rerender();
27897
- });
27928
+ const unsubscribeFromData = subscribeToChanges(action.dataSignal, rerender);
27898
27929
  // The params say WHICH question this is, and this hook reads them: to know
27899
27930
  // there is nothing to ask for, and to start the run it owns. A binding
27900
27931
  // retargeting from no question to a question — a filter chosen, a first
27901
27932
  // character typed — is announced by nothing above: a fresh target is IDLE
27902
27933
  // holding no data, the very state the hook already sees.
27903
- let paramsNotificationIsInitial = true;
27904
- const unsubscribeFromParams = action.paramsSignal.subscribe(() => {
27905
- if (paramsNotificationIsInitial) {
27906
- paramsNotificationIsInitial = false;
27907
- return;
27908
- }
27909
- rerender();
27910
- });
27934
+ const unsubscribeFromParams = subscribeToChanges(action.paramsSignal, rerender);
27911
27935
  // A debounced binding waits before it retargets, so nothing above changes
27912
27936
  // while the delay runs — but what is on screen is already out of date.
27913
- let settlingNotificationIsInitial = true;
27914
- const unsubscribeFromParamsSettling = action.paramsSettlingSignal.subscribe(() => {
27915
- if (settlingNotificationIsInitial) {
27916
- settlingNotificationIsInitial = false;
27917
- return;
27918
- }
27937
+ const unsubscribeFromParamsSettling = subscribeToChanges(action.paramsSettlingSignal, rerender);
27938
+ // Subscribed once the browser has painted, so the action may have moved
27939
+ // since the render: a run that very render started may be over already,
27940
+ // and nothing would say so again. What the render read, against what is
27941
+ // there now — and nothing moved, nothing to draw again.
27942
+ const read = readRef.current;
27943
+ if (action.runningStateSignal.peek() !== read.runningState || action.dataSignal.peek() !== read.data || action.paramsSignal.peek() !== read.params || action.paramsSettlingSignal.peek() !== read.settling) {
27919
27944
  rerender();
27920
- });
27945
+ }
27921
27946
  return () => {
27922
27947
  unsubscribeFromRunningState();
27923
27948
  unsubscribeFromData();
@@ -39345,6 +39370,17 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
39345
39370
  decisionDeferredToRef.current = popupMounting;
39346
39371
  return;
39347
39372
  }
39373
+ if (
39374
+ (el.tagName === "DIALOG" || el.hasAttribute("popover")) &&
39375
+ !isAncestorOpen(el)
39376
+ ) {
39377
+ // Itself a closed <dialog> or [popover]: display:none by the UA's own
39378
+ // rules, which the markup says without asking the layout — asked, every
39379
+ // closed popup mounting would force a style recalculation, and a list
39380
+ // whose rows each hold a few mounts hundreds of them. The observer
39381
+ // below fires as for any element the ancestor hides.
39382
+ return;
39383
+ }
39348
39384
  if (!isDisplayedDespiteClosedAncestor(el, ancestor)) {
39349
39385
  // Ancestor is closed and took this element off screen with it — skip
39350
39386
  // now; the observeAncestorOpenState call below will fire once it
@@ -51447,9 +51483,9 @@ const FixedBar = ({
51447
51483
  * A page that overflows horizontally is a bug wherever it happens, and on
51448
51484
  * Chrome Android it is a catastrophic one: the layout viewport inflates to the
51449
51485
  * content and `position: fixed` centering goes with it (see
51450
- * docs/mobile_layout_pitfalls.md). The remedy there is a wrapper in
51451
- * `overflow-x: clip`, and it works — at the price of making the cause
51452
- * invisible: nothing sticks out anymore, so nothing says a fixed width, a
51486
+ * docs/safe_area.md#a-document-wider-than-the-screen). The remedy there is a
51487
+ * wrapper in `overflow-x: clip`, and it works — at the price of making the
51488
+ * cause invisible: nothing sticks out anymore, so nothing says a fixed width, a
51453
51489
  * `min-width` or an unbreakable string is still oversized. This puts the
51454
51490
  * signal back, without giving up the net.
51455
51491
  *
@@ -64379,7 +64415,9 @@ const css$E = /* css */`
64379
64415
  * @param {"always"|"idle"|"from-first-open"|"while-opened"} [props.mount] - When
64380
64416
  * `children` are built and thrown away (see popup_content_mount.js).
64381
64417
  * `"from-first-open"` (the default) builds them on the first open and keeps
64382
- * them afterwards. `"always"` builds them right away, for content something
64418
+ * them afterwards — and the dialog itself is built then too: until its first
64419
+ * request (an open, a press on what opens it, its signal saying open) it is
64420
+ * a bare `<dialog>` holding its id (see docs/popup_open.md). `"always"` builds them right away, for content something
64383
64421
  * depends on while the popup is still closed: a value read off it, fields a
64384
64422
  * surrounding form collects on submit, a size measured from outside.
64385
64423
  * `"idle"` builds them in a browser idle moment after load — "always" minus
@@ -64400,11 +64438,122 @@ const Dialog = props => {
64400
64438
  ...props
64401
64439
  });
64402
64440
  }
64403
- return jsx(UncontrolledDialog, {
64441
+ return jsx(DialogStandIn, {
64404
64442
  ...props
64405
64443
  });
64406
64444
  };
64407
64445
 
64446
+ // A dialog nobody has asked for yet stands in as its bare <dialog>. That
64447
+ // element is all that reaches a closed dialog before its first opening: a
64448
+ // command finds it by id (`commandfor`) and asks it to open, a press on what
64449
+ // opens it announces itself on it (navi_open_press), --navi-toggle reads its
64450
+ // aria-expanded. The dialog proper — its open controller, its box, its focus
64451
+ // and pseudo-class wiring — is built at the first request: an open, a press
64452
+ // about to open it, its signal or its `open` saying open. A list whose rows
64453
+ // each hold a few dialogs mounts hundreds of them, and would pay for every one
64454
+ // at every mount, for openings that mostly never come. Built once, the dialog
64455
+ // stays: the next opening finds it there.
64456
+ const DialogStandIn = props => {
64457
+ const {
64458
+ ref,
64459
+ id,
64460
+ signal,
64461
+ value,
64462
+ open
64463
+ } = props;
64464
+ const ownRef = useRef();
64465
+ const dialogRef = ref || ownRef;
64466
+ // Why the dialog proper exists, once it does; never undone.
64467
+ const builtRef = useRef(null);
64468
+ const mountedRef = useRef(false);
64469
+ const isMount = !mountedRef.current;
64470
+ mountedRef.current = true;
64471
+ // Its own reading of the signal: the dialog of one card among many, told
64472
+ // apart by `value`, follows whether IT is the one open rather than every
64473
+ // write the others cause.
64474
+ const openedSignal = useMemo(() => signal ? computed(() => readOpened(signal.value, value)) : null, [signal, value]);
64475
+ const openedBySignal = openedSignal ? openedSignal.value : false;
64476
+ if (builtRef.current === null) {
64477
+ if (isMount && (needsDialogFromMount(props) || open || openedBySignal)) {
64478
+ builtRef.current = "mount";
64479
+ } else if (open || openedBySignal) {
64480
+ // Asked open while this stood in: the opening is happening now, and it
64481
+ // plays like any other (see defaultOpen "interaction").
64482
+ builtRef.current = "interaction";
64483
+ }
64484
+ }
64485
+ const [, setBuildCount] = useState(0);
64486
+ const warmRef = useRef(null);
64487
+ const build = reason => {
64488
+ if (builtRef.current !== null) {
64489
+ return;
64490
+ }
64491
+ builtRef.current = reason;
64492
+ flushSyncRendering(() => {
64493
+ setBuildCount(count => count + 1);
64494
+ });
64495
+ };
64496
+ const builtReason = builtRef.current;
64497
+ if (builtReason !== null) {
64498
+ return jsx(UncontrolledDialog, {
64499
+ ...props,
64500
+ ref: dialogRef,
64501
+ defaultOpen: builtReason === "interaction" ? "interaction" : props.defaultOpen
64502
+ });
64503
+ }
64504
+ return jsx("dialog", {
64505
+ ref: dialogRef,
64506
+ id: id,
64507
+ className: "navi_dialog"
64508
+ // eslint-disable-next-line react/no-unknown-property
64509
+ ,
64510
+ "navi-hidden": ""
64511
+ // eslint-disable-next-line react/no-unknown-property
64512
+ ,
64513
+ "navi-out-of-flow": "",
64514
+ "aria-expanded": "false",
64515
+ "data-testid": props["data-testid"]
64516
+ // eslint-disable-next-line react/no-unknown-property
64517
+ ,
64518
+ onnavi_request_open: e => {
64519
+ // Built in this same event, and asked again: the opening is the one
64520
+ // the dialog proper would have had — its source, its anchor, its value.
64521
+ build("request");
64522
+ const opened = dispatchCustomEvent(dialogRef.current, "navi_request_open", e.detail);
64523
+ if (!opened) {
64524
+ e.preventDefault();
64525
+ }
64526
+ }
64527
+ // eslint-disable-next-line react/no-unknown-property
64528
+ ,
64529
+ onnavi_open_press: () => {
64530
+ // What the opening will read is asked for at once, as the dialog
64531
+ // proper does (see open_controller.js); the dialog itself two frames
64532
+ // later, so the pressed trigger is painted first — the click that
64533
+ // follows opens it built, or builds it itself when it comes sooner.
64534
+ if (signal) {
64535
+ preloadState(signal, readOpenValue(value));
64536
+ }
64537
+ if (warmRef.current) {
64538
+ return;
64539
+ }
64540
+ warmRef.current = requestFrameAfterNext(() => {
64541
+ warmRef.current = null;
64542
+ build("press");
64543
+ });
64544
+ }
64545
+ });
64546
+ };
64547
+ // What needs the dialog proper from the start: an opening at mount, content
64548
+ // built ahead of any opening, a history entry to read, an anchor whose hover
64549
+ // or focus builds the content ahead of the click.
64550
+ const needsDialogFromMount = props => {
64551
+ if (props.defaultOpen || props.navState || props.anchor !== undefined) {
64552
+ return true;
64553
+ }
64554
+ return props.mount !== undefined && props.mount !== "from-first-open";
64555
+ };
64556
+
64408
64557
  // No openController passed: this Dialog is used declaratively (e.g. driven
64409
64558
  // by --navi-toggle/--navi-open/--navi-close commands, the `open` prop, or
64410
64559
  // `defaultOpen`) rather than owned by a parent component.
@@ -76033,10 +76182,10 @@ const useBadgeRegistry = (children, enabled) => {
76033
76182
  * list renders nothing at all. In a <Picker> this is the only placeholder
76034
76183
  * the user gets — a picker given a `ui` does not draw its own — so pass the
76035
76184
  * placeholder text: `fallback="Select skills…"`. Plain text is the right
76036
- * choice there: it reads at the picker's size, in the placeholder color the
76037
- * picker gives its empty value slot, and the box stays the same height. A
76038
- * transparent <Badge> matches the slot to the pixel instead, at the price of
76039
- * badge-sized text. See docs/badge_list.md.
76185
+ * choice there: it reads at the picker's size, in the normal text color (a
76186
+ * caller's `ui` is never greyed as a placeholder), and the box stays the same
76187
+ * height. A transparent <Badge> matches the slot to the pixel instead, at the
76188
+ * price of badge-sized text. See docs/typography.md#badgelist.
76040
76189
  * @param {boolean} [shrinkWrap]
76041
76190
  * Narrows the list down to its widest row so the last row isn't ragged.
76042
76191
  * Defaults to true inside a <Picker> — the trigger draws a border around the