@jsenv/navi 0.29.15 → 0.29.16

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.
@@ -6490,6 +6490,10 @@ const POSITION_PROPS = {
6490
6490
  fixed: applyToCssPropWhenTruthy("position", "fixed", "static"),
6491
6491
  sticky: applyToCssPropWhenTruthy("position", "sticky", "static"),
6492
6492
  zIndex: PASS_THROUGH,
6493
+ // Keeps the zIndex values used inside this box local to it — see
6494
+ // docs/z_index.md: a z-index that opens no stacking context competes with
6495
+ // the whole page, fixed bars included.
6496
+ isolation: PASS_THROUGH,
6493
6497
  order: PASS_THROUGH,
6494
6498
  left: (value) => {
6495
6499
  return { left: value === true ? 0 : value };
@@ -8905,6 +8909,20 @@ import.meta.css = [/* css */`
8905
8909
  between them — and a control flush against the edge of a scrolling area
8906
8910
  overflows it (a focus outline is drawn outside the control it belongs to)
8907
8911
  and raises a scrollbar of its own. */
8912
+ /* A control sitting right against the edge of what scrolls must keep its
8913
+ loading outline within its own box: the outline is drawn a couple pixels
8914
+ outside the control (see loading_outline.jsx), and that bleed alone is
8915
+ enough to make the area scrollable — a scrollbar appearing and disappearing
8916
+ as things load. Only what the scroller directly contains is against that
8917
+ edge; anything nested deeper has room around it and keeps the outline it
8918
+ asked for, hence the child combinators. Written on the outline itself
8919
+ rather than on the control, because the var inherits: setting it on a
8920
+ container would reach every control below it, edge or not. */
8921
+ [data-scrollable] > .navi_loading_outline_wrapper,
8922
+ [data-scrollable] > * > .navi_loading_outline_wrapper {
8923
+ --loading-outline-min-inset: 0px;
8924
+ }
8925
+
8908
8926
  [data-scrollable] {
8909
8927
  overflow: var(--x-scrollable-overflow, auto);
8910
8928
 
@@ -10057,6 +10075,15 @@ const LoadingOutlineUI = props => {
10057
10075
  } = props;
10058
10076
  const shouldShowSpinner = useDebounceTrue(loading, debounce);
10059
10077
  const rectangleRef = useRef(null);
10078
+
10079
+ // Nothing in the DOM until something actually loads: the box below is
10080
+ // absolutely positioned slightly outside the control, which is enough to
10081
+ // make an ancestor scrollable (a 1px scrollbar on a control sitting against
10082
+ // the edge of a scrolling area). A control that never loads must not pay for
10083
+ // a decoration it will never draw.
10084
+ if (!loading) {
10085
+ return children;
10086
+ }
10060
10087
  let insetTop = inset + spacingTop + marginTop;
10061
10088
  let insetRight = inset + spacingRight + marginRight;
10062
10089
  let insetBottom = inset + spacingBottom + marginBottom;
@@ -10087,7 +10114,7 @@ const LoadingOutlineUI = props => {
10087
10114
  "--loading-rectangle-bottom": `${insetBottom}px`,
10088
10115
  "--loading-rectangle-left": `${insetLeft}px`
10089
10116
  },
10090
- children: loading && jsx(LoadingIndicatorFluid, {
10117
+ children: jsx(LoadingIndicatorFluid, {
10091
10118
  visuallyHidden: !shouldShowSpinner,
10092
10119
  radius: radius,
10093
10120
  color: color,
@@ -17794,6 +17821,104 @@ const useExecuteAction = (
17794
17821
  return executeAction;
17795
17822
  };
17796
17823
 
17824
+ /**
17825
+ * A control placed inside a region that expands on click — a `<summary>`, an
17826
+ * accordion header carrying `aria-expanded` — has its click read twice: once by
17827
+ * the control it was aimed at, once by the region around it. The second reading
17828
+ * is never wanted; a menu opened from a collapsed row should not also unfold the
17829
+ * row.
17830
+ *
17831
+ * Cancelling the click is the only way to stop the region: a `<summary>` runs
17832
+ * its default action after the propagation, so `stopPropagation` does not reach
17833
+ * it. And it can only be done once the control has taken the click for itself —
17834
+ * navi refuses an interaction on an already-cancelled event (see
17835
+ * `onRequestInteraction`), so cancelling any earlier silences the control
17836
+ * instead of the region.
17837
+ *
17838
+ * That moment — right after an interaction was allowed — only exists inside
17839
+ * navi, which is why the cancellation lives here rather than in application code.
17840
+ */
17841
+
17842
+ const CLICK_TO_EXPAND_SELECTOR = "summary, [aria-expanded]";
17843
+
17844
+ /**
17845
+ * Cancels `event` when the control consumed a click that a surrounding
17846
+ * click-to-expand region would otherwise read as "unfold me".
17847
+ *
17848
+ * Does nothing when cancelling the click would also cancel what the control
17849
+ * itself does with it (a link navigating, a checkbox toggling): there, the two
17850
+ * behaviours cannot be separated and the control's own comes first.
17851
+ */
17852
+ const preventClickToExpand = (element, event) => {
17853
+ if (!event || event.type !== "click") {
17854
+ return;
17855
+ }
17856
+ if (event.defaultPrevented) {
17857
+ return;
17858
+ }
17859
+ if (!clickDefaultActionIsInert(element, event)) {
17860
+ return;
17861
+ }
17862
+ const parentElement = element.parentElement;
17863
+ if (!parentElement) {
17864
+ return;
17865
+ }
17866
+ // From the parent: a control that opens something carries its own
17867
+ // `aria-expanded` and would find itself.
17868
+ const clickToExpandRegion = parentElement.closest(CLICK_TO_EXPAND_SELECTOR);
17869
+ if (!clickToExpandRegion) {
17870
+ return;
17871
+ }
17872
+ event.preventDefault();
17873
+ };
17874
+
17875
+ const clickDefaultActionIsInert = (element, event) => {
17876
+ if (!isInertOnClick(element)) {
17877
+ return false;
17878
+ }
17879
+ // The activation belongs to what was clicked, which can be deeper than the
17880
+ // control host (a button inside it) or above it (a label wrapping it).
17881
+ const { target } = event;
17882
+ if (target && target !== element && target.nodeType === 1) {
17883
+ let ancestor = target;
17884
+ while (ancestor) {
17885
+ if (!isInertOnClick(ancestor)) {
17886
+ return false;
17887
+ }
17888
+ ancestor = ancestor.parentElement;
17889
+ }
17890
+ }
17891
+ return true;
17892
+ };
17893
+
17894
+ const NON_INERT_INPUT_TYPE_SET = new Set([
17895
+ "checkbox",
17896
+ "radio",
17897
+ "submit",
17898
+ "reset",
17899
+ "image",
17900
+ "file",
17901
+ ]);
17902
+
17903
+ const isInertOnClick = (element) => {
17904
+ const { tagName } = element;
17905
+ if (tagName === "A" || tagName === "AREA") {
17906
+ return !element.hasAttribute("href");
17907
+ }
17908
+ if (tagName === "LABEL") {
17909
+ // A label forwards the click to its control, whose activation would be
17910
+ // cancelled along with the click.
17911
+ return false;
17912
+ }
17913
+ if (tagName === "INPUT") {
17914
+ return !NON_INERT_INPUT_TYPE_SET.has(element.type);
17915
+ }
17916
+ if (tagName === "BUTTON") {
17917
+ return element.type === "button";
17918
+ }
17919
+ return true;
17920
+ };
17921
+
17797
17922
  const BUSY_CONSTRAINT = {
17798
17923
  name: "busy",
17799
17924
  messageAttribute: "data-busy-message",
@@ -18162,6 +18287,9 @@ const onRequestInteraction = (
18162
18287
  debugInteraction(event, `"${name}" allowed`);
18163
18288
  allowed?.();
18164
18289
  always?.();
18290
+ // The click served this control; it must not serve a second time whatever
18291
+ // unfolds around it (see click_to_expand.js).
18292
+ preventClickToExpand(controlHost, event);
18165
18293
  return true;
18166
18294
  };
18167
18295
 
@@ -25732,8 +25860,8 @@ const css$T = /* css */`
25732
25860
  * the screen and a centered box ends up both cramped and out of thumb
25733
25861
  * reach, while under a mouse the centered box is already the right shape —
25734
25862
  * hence a prop that only ever does something on touch. It supplies defaults
25735
- * for `positionArea`, `marginWithContainer` and `expandX`, so any of the
25736
- * three can still be pinned explicitly. Keyed off `(pointer: coarse)` (the
25863
+ * for `positionArea`, `marginWithContainer`, `expandX` and `scrollCapture`,
25864
+ * so any of them can still be pinned explicitly. Keyed off `(pointer: coarse)` (the
25737
25865
  * input device, not a width breakpoint — a narrow desktop window is still a
25738
25866
  * mouse) via `coarsePointerSignal`, so it re-resolves live.
25739
25867
  * @param {string} [props.positionArea="center"] - Where to dock the dialog
@@ -25770,7 +25898,7 @@ const css$T = /* css */`
25770
25898
  * A `layer="local"` dialog always locks its own positioned ancestor's
25771
25899
  * scroll while open (its backdrop only covers the scrollport, so scrolling
25772
25900
  * there would reveal uncovered content); this prop extends the lock to the
25773
- * whole page.
25901
+ * whole page. Defaults to `true` for a dialog docked by `dockedOnTouch`.
25774
25902
  * @param {boolean|"auto"|"fading"|"scaling"|"sliding"|`slide-from-${string}`} [props.animation]
25775
25903
  * - `true`/`"auto"` resolves to `"scaling"` for a centered `positionArea`,
25776
25904
  * or a concrete `"slide-from-*"` direction otherwise. Any other explicit
@@ -25939,7 +26067,11 @@ const DialogLocal = props => {
25939
26067
  const DOCKED = {
25940
26068
  positionArea: "bottom",
25941
26069
  marginWithContainer: 0,
25942
- expandX: true
26070
+ expandX: true,
26071
+ // A sheet resting on the bottom edge is dragged with a thumb, and a drag that
26072
+ // runs past its own edge must not land on the page behind it: the same
26073
+ // reasoning as "bottom" above, applied to the gesture instead of the shape.
26074
+ scrollCapture: true
25943
26075
  };
25944
26076
 
25945
26077
  // The first control inside `dialogEl` that is mid-action, if any. Walks the
@@ -25985,7 +26117,7 @@ const useDialogProps = props => {
25985
26117
  // there's no native inert-ing, so the real backdrop below is what
25986
26118
  // actually makes "capture"/"none" behave the same way here too.
25987
26119
  pointerInteractionOutsideEffect = "close",
25988
- scrollCapture,
26120
+ scrollCapture: scrollCaptureProp,
25989
26121
  animation,
25990
26122
  // Only ever affects --anchor-width/--anchor-height (see this file's top
25991
26123
  // comment) — Dialog's own positioning is never relative to it.
@@ -26020,6 +26152,7 @@ const useDialogProps = props => {
26020
26152
  const expandXUnset = expand === undefined && expandXProp === undefined;
26021
26153
  const expandX = expandXUnset ? isDocked && DOCKED.expandX : Boolean(expand) || Boolean(expandXProp);
26022
26154
  const expandY = Boolean(expand) || Boolean(expandYProp);
26155
+ const scrollCapture = scrollCaptureProp ?? (isDocked ? DOCKED.scrollCapture : false);
26023
26156
  const backdropRef = useRef();
26024
26157
  // Disarms a still-pending backdrop hide from a previous close (see
26025
26158
  // armPointerDownOutsideClose below) — same pattern as popover.jsx's own.
@@ -26579,8 +26712,13 @@ const DIALOG_PSEUDO_CLASSES = [":hover", ":active", ":focus", ":focus-visible",
26579
26712
 
26580
26713
  // Lets consumers pass animationDuration="0.5s" as a regular prop; Box maps
26581
26714
  // it to the CSS var for us (see box.jsx's styleCSSVars handling).
26715
+ // borderRadius goes through --dialog-border-radius rather than the
26716
+ // border-radius property itself so the flush-corner rules above (a plain
26717
+ // stylesheet) can still square the corners that land on the container's own —
26718
+ // an inline border-radius would outrank them.
26582
26719
  const DIALOG_STYLE_CSS_VARS = {
26583
26720
  animationDuration: "--popup-animation-duration",
26721
+ borderRadius: "--dialog-border-radius",
26584
26722
  minWidth: "--dialog-min-width",
26585
26723
  maxWidth: "--dialog-max-width",
26586
26724
  minHeight: "--dialog-min-height",
@@ -31540,12 +31678,30 @@ const useActionAsyncData = (action, {
31540
31678
  const runningState = action.runningStateSignal.peek();
31541
31679
  const [, setTick] = useState(0);
31542
31680
  useEffect(() => {
31543
- return action.runningStateSignal.subscribe(state => {
31681
+ const unsubscribeFromRunningState = action.runningStateSignal.subscribe(state => {
31544
31682
  if (state === RUNNING) {
31545
31683
  dismissedActionWeakSet.delete(action);
31546
31684
  }
31547
31685
  setTick(n => n + 1);
31548
31686
  });
31687
+ // The data does not come from this action's runs alone: dataSignal is a
31688
+ // computed over the resource store, so an other action writing that store
31689
+ // (a PUT upserting an item that a GET_MANY list already holds) changes the
31690
+ // data while this action stays COMPLETED. Subscribing here re-renders
31691
+ // through the same controlled path as the run state, instead of `.value`.
31692
+ let dataNotificationIsInitial = true;
31693
+ const unsubscribeFromData = action.dataSignal.subscribe(() => {
31694
+ if (dataNotificationIsInitial) {
31695
+ // subscribe() calls back synchronously with the current value
31696
+ dataNotificationIsInitial = false;
31697
+ return;
31698
+ }
31699
+ setTick(n => n + 1);
31700
+ });
31701
+ return () => {
31702
+ unsubscribeFromRunningState();
31703
+ unsubscribeFromData();
31704
+ };
31549
31705
  }, []);
31550
31706
  if (runningState === COMPLETED) {
31551
31707
  return [action.dataSignal.peek(), false, undefined];
@@ -48009,13 +48165,10 @@ const css$u = /* css */`
48009
48165
  font-size: 1em;
48010
48166
  line-height: 1.4;
48011
48167
  }
48012
- /* A control that IS the row — a direct child of the item, so it spans it
48013
- must keep its loading outline within its own box: the scroll container is
48014
- overflow:auto, and the couple pixels the outline normally draws outside
48015
- the control are enough to make it scrollable, so a scrollbar would appear
48016
- and disappear as things load. Targeted on the outline itself rather than
48017
- inherited from the item, so a control nested deeper (which has room around
48018
- it, and does not reach the edges) keeps the outline it asked for. */
48168
+ /* Same rule as [data-scrollable] in box.jsx, said again for this scroller:
48169
+ what an item holds IS against the edge of the scroll container — the list
48170
+ element between the two is markup, not spacing so its loading outline
48171
+ stays inside its own box rather than raising a scrollbar. */
48019
48172
  .navi_list_item > .navi_loading_outline_wrapper,
48020
48173
  .navi_list_item > * > .navi_loading_outline_wrapper,
48021
48174
  .navi_list_item_header > * > .navi_loading_outline_wrapper,
@@ -53769,6 +53922,18 @@ const css$n = /* css */`
53769
53922
  /* The control grows itself; resizable below hands the handle back. */
53770
53923
  resize: none;
53771
53924
  overflow: auto;
53925
+ /* A placeholder must be readable in full before anything is typed: a
53926
+ field that opens already scrolled reads as a field that already has
53927
+ text in it. Its wrapped height is measured (see usePlaceholderHeight)
53928
+ because it only exists once laid out, and it only raises the floor
53929
+ while the placeholder is what is being shown — what is typed sizes the
53930
+ box on its own. */
53931
+ &:placeholder-shown {
53932
+ min-height: max(
53933
+ calc(var(--textarea-min-rows, 1.5) * 1lh),
53934
+ var(--x-textarea-placeholder-height, 0px)
53935
+ );
53936
+ }
53772
53937
  }
53773
53938
  &[data-resizable] .navi_control_input {
53774
53939
  height: calc(var(--textarea-min-rows, 1.5) * 1lh);
@@ -53806,7 +53971,9 @@ const css$n = /* css */`
53806
53971
  * @param {number} [maxRows] Lines after which the control stops growing and
53807
53972
  * scrolls instead. Without it the control grows with its content.
53808
53973
  * @param {boolean} [resizable] Give the browser's vertical resize handle back.
53809
- * A manual resize takes over from the automatic growth.
53974
+ * An exchange, not an addition: the hand takes over from the automatic
53975
+ * growth, so the control stops following what is typed and stays at the
53976
+ * height it was last dragged to (starting at `minRows`).
53810
53977
  * @param {number} [maxLength] The character limit, validated at submit. Pair
53811
53978
  * with `maxLengthGuard` to block typing past it, and render a
53812
53979
  * TextareaCharCount to show it.
@@ -53828,6 +53995,7 @@ const Textarea = ({
53828
53995
  import.meta.css = [inputCss + css$n, "@jsenv/navi/src/control/input/textarea.jsx"];
53829
53996
  const defaultRef = useRef(null);
53830
53997
  props.ref = props.ref || defaultRef;
53998
+ usePlaceholderHeight(props.ref, props.placeholder);
53831
53999
  const [rootProps, hostProps, childrenWrapperProps] = useControlProps(props, {
53832
54000
  controlType: "input"
53833
54001
  });
@@ -53908,6 +54076,61 @@ const TextareaCharCount = ({
53908
54076
  children: maxLength === undefined ? length : `${length}/${maxLength}`
53909
54077
  });
53910
54078
  };
54079
+
54080
+ // `field-sizing: content` sizes the box from the value, and an empty field has
54081
+ // none — the placeholder is text the browser refuses to make room for. So the
54082
+ // height it wraps to is measured and published as --x-textarea-placeholder-height
54083
+ // for the CSS above to use as a floor.
54084
+ const usePlaceholderHeight = (ref, placeholder) => {
54085
+ useLayoutEffect(() => {
54086
+ const textareaEl = ref.current;
54087
+ if (!placeholder) {
54088
+ textareaEl.style.removeProperty("--x-textarea-placeholder-height");
54089
+ return null;
54090
+ }
54091
+ let widthMeasured;
54092
+ const measure = () => {
54093
+ // What is typed sizes the box itself; the placeholder is not displayed
54094
+ // then, and scrollHeight would report the value's height instead.
54095
+ if (textareaEl.value !== "") {
54096
+ return;
54097
+ }
54098
+ const {
54099
+ paddingTop,
54100
+ paddingBottom
54101
+ } = getComputedStyle(textareaEl);
54102
+ // Cleared before reading: scrollHeight can never report less than the
54103
+ // height already applied, so measuring on top of a previous measure could
54104
+ // only ever grow the box, never let it shrink back on a wider viewport.
54105
+ textareaEl.style.setProperty("--x-textarea-placeholder-height", "0px");
54106
+ const contentHeight = textareaEl.scrollHeight - parseFloat(paddingTop) - parseFloat(paddingBottom);
54107
+ widthMeasured = textareaEl.clientWidth;
54108
+ textareaEl.style.setProperty("--x-textarea-placeholder-height", `${contentHeight}px`);
54109
+ };
54110
+ measure();
54111
+ // The placeholder wraps against the available width, so a new width is a
54112
+ // new number of lines. Height changes are ignored: this measure is what
54113
+ // causes them, and reacting to them would be reacting to ourselves.
54114
+ const resizeObserver = new ResizeObserver(() => {
54115
+ if (textareaEl.clientWidth !== widthMeasured) {
54116
+ measure();
54117
+ }
54118
+ });
54119
+ resizeObserver.observe(textareaEl);
54120
+ // The width may have changed while the field held a value, when measuring
54121
+ // was impossible — emptying it is when the placeholder comes back.
54122
+ const onInput = () => {
54123
+ if (textareaEl.value === "") {
54124
+ measure();
54125
+ }
54126
+ };
54127
+ textareaEl.addEventListener("input", onInput);
54128
+ return () => {
54129
+ resizeObserver.disconnect();
54130
+ textareaEl.removeEventListener("input", onInput);
54131
+ };
54132
+ }, [placeholder]);
54133
+ };
53911
54134
  const RealTextarea = ({
53912
54135
  maxLength,
53913
54136
  ...domProps