@trackunit/react-form-components 2.6.59 → 2.6.61

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.
package/index.cjs.js CHANGED
@@ -1653,7 +1653,10 @@ const cvaSelectMenu = cssClassVarianceUtilities.cva({
1653
1653
  placement: "bottom",
1654
1654
  },
1655
1655
  });
1656
- const cvaSelectMenuList = cssClassVarianceUtilities.cva({ base: [reactComponents.cvaMenuList(), "relative", "w-full"] });
1656
+ // `overscroll-contain`: react-select's `captureMenuScroll` defaults to off on touch devices, so
1657
+ // without this a scroll at the list's top/bottom chains to the container behind the menu, moving
1658
+ // the control and dragging the anchored menu with it.
1659
+ const cvaSelectMenuList = cssClassVarianceUtilities.cva({ base: [reactComponents.cvaMenuList(), "relative", "w-full", "overscroll-contain"] });
1657
1660
  const cvaSelectPlaceholder = cssClassVarianceUtilities.cva({ base: ["absolute", "text-neutral-400"] });
1658
1661
  const cvaSelectIndicatorsContainer = cssClassVarianceUtilities.cva({ base: ["flex", "items-center", "gap-x-1"] });
1659
1662
  const cvaSelectMultiValue = cssClassVarianceUtilities.cva({
@@ -2387,6 +2390,20 @@ const getVisibleCountFromGeometries = ({ geometries, availableWidth, containerX,
2387
2390
  return visibleCount;
2388
2391
  };
2389
2392
 
2393
+ // Chrome can report a fractional `scrollTop` at the physical bottom, so an exact
2394
+ // `>= scrollHeight` check never matches there.
2395
+ const BOTTOM_SCROLL_TOLERANCE_PX = 1;
2396
+ const MENU_VERTICAL_BORDER_CHROME_PX = 2;
2397
+ const DEFAULT_REACT_SELECT_MIN_MENU_HEIGHT_PX = 140;
2398
+ /**
2399
+ * `useSelect` deliberately leaves `minMenuHeight` unset so react-select keeps owning the menu's
2400
+ * placement policy, which means the effective value comes from the library's own
2401
+ * `defaultProps.minMenuHeight`. react-select's types declare the prop as required for that
2402
+ * reason, but nothing guarantees it at runtime -- and interpolating an absent value into CSS
2403
+ * emits `undefinedpx`, which makes browsers discard the whole `max-height` declaration. The
2404
+ * optional parameter is what lets the fallback be expressed despite the required type.
2405
+ */
2406
+ const resolveMinMenuHeight = (minMenuHeight) => minMenuHeight ?? DEFAULT_REACT_SELECT_MIN_MENU_HEIGHT_PX;
2390
2407
  /**
2391
2408
  * A hook to retrieve components override object.
2392
2409
  * This complex object includes all the compositional components that are used in react-select. If you wish to overwrite a component, pass in an object with the appropriate namespace.
@@ -2399,7 +2416,7 @@ const getVisibleCountFromGeometries = ({ geometries, availableWidth, containerX,
2399
2416
  */
2400
2417
  const useCustomComponents = ({ disabled, readOnly, "data-testid": dataTestId, prefix, hasError, fieldSize = "medium", getOptionLabelDescription, getOptionPrefix, className, isMulti, //prefer using the component prop (ala. selectValueContainer.isMulti) inside of customComponents instead of this one.
2401
2418
  autoComplete, // see https://github.com/JedWatson/react-select/issues/758
2402
- formatOptionLabel, getMenuGeometry, }) => {
2419
+ formatOptionLabel, onMenuScrollToBottom, getMenuGeometry, setMenuListRef, }) => {
2403
2420
  const [t] = useTranslation();
2404
2421
  const { setValueContainerRef, setCounterRef, setFakeCounterRef, setGeometryRef, setMenuRef, getVisibleCount, getTotalCount, getCounterWidth, getIsComplete, } = useMultiValueOverflow({ skip: !isMulti });
2405
2422
  const interactable = react.useMemo(() => !Boolean(disabled) && !Boolean(readOnly), [disabled, readOnly]);
@@ -2413,6 +2430,16 @@ formatOptionLabel, getMenuGeometry, }) => {
2413
2430
  getOptionLabelDescriptionRef.current = getOptionLabelDescription;
2414
2431
  const getOptionPrefixRef = react.useRef(getOptionPrefix);
2415
2432
  getOptionPrefixRef.current = getOptionPrefix;
2433
+ const onMenuScrollToBottomRef = react.useRef(onMenuScrollToBottom);
2434
+ onMenuScrollToBottomRef.current = onMenuScrollToBottom;
2435
+ // Keep react-select's control ref intact while retaining the live DOM node for viewport-relative
2436
+ // vertical measurements that can change without changing an absolute portal's document offset.
2437
+ const controlRef = react.useRef(null);
2438
+ // The list keeps firing `scroll` while resting at the bottom, so only an arrival there may
2439
+ // notify. `scrollHeight` is part of the latched identity because appending a page grows the list
2440
+ // without emitting a scroll event, and a latch measured against the old height must not suppress
2441
+ // the arrival at the new bottom.
2442
+ const atBottomRef = react.useRef(null);
2416
2443
  const resolveOptionPrefixRef = react.useRef(undefined);
2417
2444
  if (resolveOptionPrefixRef.current === undefined) {
2418
2445
  resolveOptionPrefixRef.current = (data) => getOptionPrefixRef.current?.(data);
@@ -2443,7 +2470,7 @@ formatOptionLabel, getMenuGeometry, }) => {
2443
2470
  esToolkit.omit(selectControl.innerProps, ["onMouseDown", "onTouchEnd"])
2444
2471
  : selectControl.innerProps),
2445
2472
  ...(dataTestId ? { "data-testid": `${dataTestId}-control` } : {}),
2446
- }, children: [prefix !== undefined ? (jsxRuntime.jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-prefix` : null, children: prefix })) : null, selectControl.children, typeof disabled === "object" ? (jsxRuntime.jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-disabled-locked` : null, children: jsxRuntime.jsx(InputLockReasonTooltip, { ...disabled }) })) : null, typeof readOnly === "object" && !Boolean(disabled) ? (jsxRuntime.jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-readonly-locked` : null, children: jsxRuntime.jsx(InputLockReasonTooltip, { ...readOnly }) })) : null] }));
2473
+ }, innerRef: reactComponents.useMergeRefs([controlRef, selectControl.innerRef]), children: [prefix !== undefined ? (jsxRuntime.jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-prefix` : null, children: prefix })) : null, selectControl.children, typeof disabled === "object" ? (jsxRuntime.jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-disabled-locked` : null, children: jsxRuntime.jsx(InputLockReasonTooltip, { ...disabled }) })) : null, typeof readOnly === "object" && !Boolean(disabled) ? (jsxRuntime.jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-readonly-locked` : null, children: jsxRuntime.jsx(InputLockReasonTooltip, { ...readOnly }) })) : null] }));
2447
2474
  },
2448
2475
  Placeholder: selectPlaceholder => {
2449
2476
  return (jsxRuntime.jsx(ReactSelect.components.Placeholder, { ...selectPlaceholder, className: cvaSelectPlaceholder({ className: selectPlaceholder.className }), getStyles: getNoStyles, children: selectPlaceholder.children }));
@@ -2513,10 +2540,12 @@ formatOptionLabel, getMenuGeometry, }) => {
2513
2540
  }), data: selectMultiValue.data, "data-testid": dataTestId ? `${dataTestId}-multiValue-${index}` : undefined, disabled: Boolean(disabled), fieldSize: fieldSize, getOptionPrefix: getOptionPrefixRef.current ? resolveOptionPrefixRef.current : undefined, onClose: selectMultiValue.removeProps.onClick, ref: setGeometryRef(index), removeTagLabel: t("select.multiValue.remove"), children: selectMultiValue.children }) }));
2514
2541
  },
2515
2542
  Menu: selectMenu => {
2543
+ const geometry = getMenuGeometry();
2544
+ const controlRect = controlRef.current?.getBoundingClientRect();
2516
2545
  return (jsxRuntime.jsx(ReactSelect.components.Menu, { ...selectMenu, className: cvaSelectMenu({ className: selectMenu.className, placement: selectMenu.placement }), getStyles: getNoStyles, innerProps: {
2517
2546
  ...selectMenu.innerProps,
2518
2547
  ...(dataTestId ? { "data-testid": `${dataTestId}-menu` } : {}),
2519
- style: getMenuGeometryStyle(getMenuGeometry()),
2548
+ style: getMenuGeometryStyle(geometry, controlRect, selectMenu.placement),
2520
2549
  },
2521
2550
  // `innerRef` (not `innerProps.ref`) is the actual DOM ref react-select's `MenuPlacer` reads
2522
2551
  // to measure available space and decide whether to flip the menu open upward -- merging our
@@ -2533,23 +2562,36 @@ formatOptionLabel, getMenuGeometry, }) => {
2533
2562
  }, children: selectMenuPortal.children }));
2534
2563
  },
2535
2564
  MenuList: selectMenuList => {
2565
+ const minMenuHeight = resolveMinMenuHeight(selectMenuList.selectProps.minMenuHeight);
2536
2566
  return (jsxRuntime.jsx(ReactSelect.components.MenuList, { className: cvaSelectMenuList({
2537
2567
  className: selectMenuList.className,
2538
2568
  }), ...selectMenuList, getStyles: getNoStyles, innerProps: {
2539
2569
  ...selectMenuList.innerProps,
2570
+ style: {
2571
+ ...selectMenuList.innerProps.style,
2572
+ // MenuPlacer may loosen its result when a later pass measures an already-constrained
2573
+ // menu. The inherited available-space bound is independent of that measured height;
2574
+ // react-select still owns which side opens and its effective minimum. Flooring only
2575
+ // the available-space term preserves that minimum as the last resort (allowing slight
2576
+ // viewport overflow) without changing normally constrained MenuPlacer heights.
2577
+ maxHeight: `min(${selectMenuList.maxHeight}px, max(var(--select-menu-available-height, 100dvh), ${minMenuHeight}px), max(40dvh, var(--spacing-64)))`,
2578
+ },
2540
2579
  onScroll: e => {
2541
2580
  const listEl = e.currentTarget;
2542
- if (listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight &&
2543
- selectMenuList.selectProps.onMenuScrollToBottom) {
2544
- if (/Firefox/.test(navigator.userAgent)) {
2545
- selectMenuList.selectProps.onMenuScrollToBottom(new WheelEvent("scroll"));
2546
- }
2547
- else {
2548
- selectMenuList.selectProps.onMenuScrollToBottom(new TouchEvent(""));
2549
- }
2581
+ const scrollHeight = listEl.scrollHeight;
2582
+ const atBottom = listEl.scrollTop + listEl.clientHeight >= scrollHeight - BOTTOM_SCROLL_TOLERANCE_PX;
2583
+ const previous = atBottomRef.current;
2584
+ const wasAtBottom = previous?.listEl === listEl && previous.atBottom && previous.scrollHeight === scrollHeight;
2585
+ atBottomRef.current = { listEl, atBottom, scrollHeight };
2586
+ if (atBottom && !wasAtBottom) {
2587
+ // Desktop Safari never defines `TouchEvent`, so constructing one there throws.
2588
+ onMenuScrollToBottomRef.current?.(new WheelEvent("scroll"));
2550
2589
  }
2551
2590
  },
2552
- }, children: selectMenuList.children }));
2591
+ },
2592
+ // Same trap as `Menu` above: `innerProps` is applied after `innerRef`, so passing a ref
2593
+ // through it would replace react-select's own list ref.
2594
+ innerRef: reactComponents.useMergeRefs([setMenuListRef, selectMenuList.innerRef]), children: selectMenuList.children }));
2553
2595
  },
2554
2596
  Option: selectOption => {
2555
2597
  const componentProps = {
@@ -2591,6 +2633,7 @@ formatOptionLabel, getMenuGeometry, }) => {
2591
2633
  setGeometryRef,
2592
2634
  setMenuRef,
2593
2635
  getMenuGeometry,
2636
+ setMenuListRef,
2594
2637
  ]);
2595
2638
  return customComponents;
2596
2639
  };
@@ -2598,21 +2641,30 @@ const getNoStyles = () => {
2598
2641
  // To prevent the default styles from being applied from react-select.
2599
2642
  return {};
2600
2643
  };
2601
- /**
2602
- * Translates the computed geometry into the menu's positioning/sizing inline style: grows/shrinks
2603
- * to fit content between `minWidth` (the control's own width) and `maxWidth` (the available space
2604
- * on the anchored side), flipping to right-aligned ("poking out" left of the control) when needed.
2605
- */
2606
- const getMenuGeometryStyle = (geometry) => {
2607
- if (!geometry) {
2644
+ const getMenuGeometryStyle = (geometry, controlRect, placement) => {
2645
+ const controlEdge = placement === "bottom"
2646
+ ? (controlRect?.bottom ?? geometry?.controlBottom)
2647
+ : (controlRect?.top ?? geometry?.controlTop);
2648
+ if (!geometry && controlEdge === undefined) {
2608
2649
  return undefined;
2609
2650
  }
2610
- return {
2611
- left: geometry.alignRight ? undefined : 0,
2612
- right: geometry.alignRight ? 0 : undefined,
2613
- minWidth: geometry.minWidth,
2614
- maxWidth: geometry.maxWidth,
2651
+ const style = {
2652
+ left: geometry?.alignRight === false ? 0 : undefined,
2653
+ right: geometry?.alignRight === true ? 0 : undefined,
2654
+ minWidth: geometry?.minWidth,
2655
+ maxWidth: geometry?.maxWidth,
2615
2656
  };
2657
+ // The gap the chosen side leaves between the control and the viewport edge, measured from the
2658
+ // control's own viewport coordinates (never react-select's portal `offset`, which carries the
2659
+ // document scroll for non-fixed menus). `dvh` keeps the bound live as mobile browser chrome
2660
+ // shows/hides, without duplicating the design ceiling or the flip policy in JS.
2661
+ if (controlEdge !== undefined) {
2662
+ style["--select-menu-available-height"] =
2663
+ placement === "bottom"
2664
+ ? `calc(100dvh - ${controlEdge}px - var(--spacing-1) - ${MENU_VERTICAL_BORDER_CHROME_PX}px)`
2665
+ : `calc(${controlEdge}px - var(--spacing-1) - ${MENU_VERTICAL_BORDER_CHROME_PX}px)`;
2666
+ }
2667
+ return style;
2616
2668
  };
2617
2669
  const getInputComponent = (children) => {
2618
2670
  return Array.isArray(children)
@@ -2636,7 +2688,8 @@ const getPlaceholderElement = (children) => {
2636
2688
  // dropdown keeps the same edge gap as other floating design-system elements.
2637
2689
  const EDGE_PADDING = 12;
2638
2690
  /**
2639
- * Derives the dropdown's alignment/sizing from the control's rect and the viewport:
2691
+ * Derives the dropdown's horizontal alignment/sizing from the control's rect and carries the
2692
+ * control's vertical edges for the separate viewport-height bound:
2640
2693
  * - flips to right-aligned ("poking out" left of the control) only when the control no
2641
2694
  * longer fits to the right AND flipping actually buys more room
2642
2695
  * - the width ceiling on the anchored side (bounded by the viewport edge, minus padding)
@@ -2660,6 +2713,8 @@ const computeMenuGeometry = (rect) => {
2660
2713
  alignRight,
2661
2714
  minWidth: rect.width,
2662
2715
  maxWidth: alignRight ? spaceLeft : spaceRight,
2716
+ ...(rect.top === undefined ? {} : { controlTop: rect.top }),
2717
+ ...(rect.bottom === undefined ? {} : { controlBottom: rect.bottom }),
2663
2718
  };
2664
2719
  };
2665
2720
 
@@ -2674,15 +2729,13 @@ const MENU_PLACEMENT_RECOMPUTE_DEBOUNCE_MS = 120;
2674
2729
  * (e.g. scrolling the modal/drawer/page the select sits in without closing the menu). A menu that
2675
2730
  * correctly opened "up" (or "down") can end up clipped once the layout around it moves.
2676
2731
  *
2677
- * `useSelect.ts` feeds the returned `tick` into `maxMenuHeight` -- the one prop in that dependency
2678
- * list safe to toggle purely to force a fresh measurement, with no other side effect: the
2679
- * resulting `maxHeight` react-select computes is never consumed on our end (the custom
2680
- * `Menu`/`MenuList` in useCustomComponents.tsx disable react-select's per-slot styling via
2681
- * `getStyles={getNoStyles}` and constrain height with Tailwind's `cvaMenuList` instead) -- only
2682
- * the *change* in value, not the value itself, matters. `minMenuHeight` must stay untouched:
2683
- * passing any finite number there (instead of `undefined`) would make react-select treat "the
2684
- * menu barely fits, if constrained" as true almost everywhere, short-circuiting past the actual
2685
- * flip check.
2732
+ * `useSelect.ts` uses the returned `tick` to alternate `maxMenuHeight` by one pixel around the
2733
+ * viewport height -- the one prop in that dependency list safe to toggle purely to force a fresh
2734
+ * measurement. The custom `MenuList` consumes react-select's computed result and combines it with
2735
+ * the design-system ceiling in CSS, so `maxMenuHeight` must stay a real viewport-scale height.
2736
+ * `minMenuHeight` must stay unset: leaving the wrapper prop `undefined` lets react-select apply
2737
+ * its own `defaultProps.minMenuHeight` (140px at runtime today), so the library stays the owner
2738
+ * of the flip policy and that effective value -- not this wrapper -- defines the boundary.
2686
2739
  *
2687
2740
  * Deliberately a trailing *debounce*, not a per-frame (rAF) throttle: `resize` (and, to a lesser
2688
2741
  * extent, `scroll`) fires continuously while the user is actively dragging, so a control that
@@ -2690,16 +2743,34 @@ const MENU_PLACEMENT_RECOMPUTE_DEBOUNCE_MS = 120;
2690
2743
  * flip decision -- and therefore the whole menu -- recomputed and visibly toggle top/bottom on
2691
2744
  * practically every frame of the drag. Waiting until the gesture actually settles means the menu
2692
2745
  * still only flips (at most) once, to whatever's correct for the final size/position.
2746
+ *
2747
+ * `setMenuListRef` must be wired to the rendered MenuList's DOM node so scrolling the dropdown's
2748
+ * own option list can be told apart from the surrounding layout moving.
2693
2749
  */
2694
2750
  const useMenuPlacementRecompute = () => {
2695
2751
  const [tick, setTick] = react.useState(0);
2696
2752
  const cleanupRef = react.useRef(null);
2753
+ const menuListRef = react.useRef(null);
2754
+ const setMenuListRef = react.useCallback(element => {
2755
+ menuListRef.current = element;
2756
+ }, []);
2697
2757
  const onMenuOpen = react.useCallback(() => {
2698
2758
  if (cleanupRef.current) {
2699
2759
  return; // already watching
2700
2760
  }
2761
+ // Refresh maxMenuHeight immediately on each arrival. Resize events are intentionally ignored
2762
+ // while closed, so without this bump a menu opened after such a resize could reuse a viewport
2763
+ // height memoized before the resize.
2764
+ setTick(previousTick => previousTick + 1);
2701
2765
  let timeoutId = null;
2702
- const handleReposition = () => {
2766
+ const handleReposition = (event) => {
2767
+ // Scrolling the dropdown's own option list doesn't move the control, and recomputing on it
2768
+ // is harmful: `MenuPlacer` measures the space below the menu *where it currently sits*, so a
2769
+ // menu already flipped up has room "below", flips down, and toggles on the next scroll.
2770
+ const target = event?.target;
2771
+ if (target instanceof Node && menuListRef.current?.contains(target) === true) {
2772
+ return;
2773
+ }
2703
2774
  if (timeoutId !== null) {
2704
2775
  clearTimeout(timeoutId);
2705
2776
  }
@@ -2727,7 +2798,7 @@ const useMenuPlacementRecompute = () => {
2727
2798
  }, []);
2728
2799
  // Stop watching if the component unmounts while the menu is still open.
2729
2800
  react.useEffect(() => onMenuClose, [onMenuClose]);
2730
- return react.useMemo(() => ({ tick, onMenuOpen, onMenuClose }), [tick, onMenuOpen, onMenuClose]);
2801
+ return react.useMemo(() => ({ tick, onMenuOpen, onMenuClose, setMenuListRef }), [tick, onMenuOpen, onMenuClose, setMenuListRef]);
2731
2802
  };
2732
2803
 
2733
2804
  /**
@@ -2776,6 +2847,17 @@ const useSelect = (props) => {
2776
2847
  },
2777
2848
  }), []);
2778
2849
  const getMenuGeometry = react.useCallback(() => menuGeometryRef.current, []);
2850
+ // Forces react-select to redo its up/down flip decision on scroll/resize while the menu stays
2851
+ // open -- see useMenuPlacementRecompute's own comment for why this is otherwise stuck at the
2852
+ // decision made when the menu was first opened. `onMenuOpen`/`onMenuClose`/`setMenuListRef` are
2853
+ // stable (useCallback with no deps), so destructuring them here doesn't affect handleMenuOpen/
2854
+ // handleMenuClose's own stability below -- only `tick` (a primitive) is expected to change.
2855
+ const { tick: menuPlacementRecomputeTick, onMenuOpen: startMenuRepositionWatcher, onMenuClose: stopMenuRepositionWatcher, setMenuListRef, } = useMenuPlacementRecompute();
2856
+ // Read on every useSelect render rather than inside the final useMemo: opening bumps the tick,
2857
+ // so a viewport resize that happened while the menu was closed cannot leave this value stale.
2858
+ // The alternating pixel only re-triggers MenuPlacer; the rendered MenuList retains the exact
2859
+ // design-system ceiling in CSS and combines it with MenuPlacer's computed result.
2860
+ const maxMenuHeight = window.innerHeight + (menuPlacementRecomputeTick % 2);
2779
2861
  const customComponents = useCustomComponents({
2780
2862
  disabled, // intentionally not evaluated as boolean, since it can be object too!
2781
2863
  readOnly: readOnly ?? false, // intentionally not evaluated as boolean, since it can be object too!
@@ -2789,7 +2871,9 @@ const useSelect = (props) => {
2789
2871
  className: stableRestProps.className,
2790
2872
  autoComplete: stableRestProps.autoComplete,
2791
2873
  formatOptionLabel,
2874
+ onMenuScrollToBottom,
2792
2875
  getMenuGeometry,
2876
+ setMenuListRef,
2793
2877
  });
2794
2878
  const interactable = react.useMemo(() => !Boolean(disabled) && !Boolean(readOnly), [disabled, readOnly]);
2795
2879
  // Determine the portal target for the menu
@@ -2797,12 +2881,6 @@ const useSelect = (props) => {
2797
2881
  // Use custom scroll blocking hook to prevent layout shifts
2798
2882
  // Pass the portal target so we only block scroll when menu is portaled to document.body
2799
2883
  const { blockScroll, restoreScroll } = reactComponents.useScrollBlock(portalTarget);
2800
- // Forces react-select to redo its up/down flip decision on scroll/resize while the menu stays
2801
- // open -- see useMenuPlacementRecompute's own comment for why this is otherwise stuck at the
2802
- // decision made when the menu was first opened. `onMenuOpen`/`onMenuClose` are stable
2803
- // (useCallback with no deps), so destructuring them here doesn't affect handleMenuOpen/
2804
- // handleMenuClose's own stability below -- only `tick` (a primitive) is expected to change.
2805
- const { tick: menuPlacementRecomputeTick, onMenuOpen: startMenuRepositionWatcher, onMenuClose: stopMenuRepositionWatcher, } = useMenuPlacementRecompute();
2806
2884
  // Store only the wrapped callbacks in refs to keep them stable
2807
2885
  // We only do this for onMenuOpen and onMenuClose because we wrap them with additional logic
2808
2886
  // Other callbacks are passed through directly and should trigger recalculation when they change
@@ -2902,16 +2980,15 @@ const useSelect = (props) => {
2902
2980
  isOptionSelected,
2903
2981
  onKeyDown,
2904
2982
  onMenuScrollToTop,
2905
- onMenuScrollToBottom,
2906
2983
  // Wire up our wrapped menu open/close handlers (with scroll blocking)
2907
2984
  onMenuOpen: handleMenuOpen,
2908
2985
  onMenuClose: handleMenuClose,
2909
2986
  // 👇 putting these here to avoid them _accidentally_ being overwritten in the future👇
2910
- // Not "controlled custom components styling" like `minMenuHeight` below -- this value is
2911
- // otherwise inert (see useMenuPlacementRecompute), it only exists to force react-select to
2912
- // redo its up/down flip decision whenever it changes.
2913
- maxMenuHeight: menuPlacementRecomputeTick,
2914
- minMenuHeight: undefined, // controlled custom components styling
2987
+ // A real viewport-scale preferred height lets MenuPlacer calculate available space. The
2988
+ // custom MenuList combines its result with the design ceiling in CSS.
2989
+ maxMenuHeight,
2990
+ // Leave unset so react-select owns its effective minimum and placement policy.
2991
+ minMenuHeight: undefined,
2915
2992
  theme: undefined,
2916
2993
  classNames: undefined,
2917
2994
  styles: menuPortalStyles,
@@ -2963,11 +3040,10 @@ const useSelect = (props) => {
2963
3040
  isOptionSelected,
2964
3041
  onKeyDown,
2965
3042
  onMenuScrollToTop,
2966
- onMenuScrollToBottom,
2967
3043
  handleMenuOpen,
2968
3044
  handleMenuClose,
2969
3045
  menuPortalStyles,
2970
- menuPlacementRecomputeTick,
3046
+ maxMenuHeight,
2971
3047
  ]);
2972
3048
  };
2973
3049
 
package/index.esm.js CHANGED
@@ -1652,7 +1652,10 @@ const cvaSelectMenu = cva({
1652
1652
  placement: "bottom",
1653
1653
  },
1654
1654
  });
1655
- const cvaSelectMenuList = cva({ base: [cvaMenuList(), "relative", "w-full"] });
1655
+ // `overscroll-contain`: react-select's `captureMenuScroll` defaults to off on touch devices, so
1656
+ // without this a scroll at the list's top/bottom chains to the container behind the menu, moving
1657
+ // the control and dragging the anchored menu with it.
1658
+ const cvaSelectMenuList = cva({ base: [cvaMenuList(), "relative", "w-full", "overscroll-contain"] });
1656
1659
  const cvaSelectPlaceholder = cva({ base: ["absolute", "text-neutral-400"] });
1657
1660
  const cvaSelectIndicatorsContainer = cva({ base: ["flex", "items-center", "gap-x-1"] });
1658
1661
  const cvaSelectMultiValue = cva({
@@ -2386,6 +2389,20 @@ const getVisibleCountFromGeometries = ({ geometries, availableWidth, containerX,
2386
2389
  return visibleCount;
2387
2390
  };
2388
2391
 
2392
+ // Chrome can report a fractional `scrollTop` at the physical bottom, so an exact
2393
+ // `>= scrollHeight` check never matches there.
2394
+ const BOTTOM_SCROLL_TOLERANCE_PX = 1;
2395
+ const MENU_VERTICAL_BORDER_CHROME_PX = 2;
2396
+ const DEFAULT_REACT_SELECT_MIN_MENU_HEIGHT_PX = 140;
2397
+ /**
2398
+ * `useSelect` deliberately leaves `minMenuHeight` unset so react-select keeps owning the menu's
2399
+ * placement policy, which means the effective value comes from the library's own
2400
+ * `defaultProps.minMenuHeight`. react-select's types declare the prop as required for that
2401
+ * reason, but nothing guarantees it at runtime -- and interpolating an absent value into CSS
2402
+ * emits `undefinedpx`, which makes browsers discard the whole `max-height` declaration. The
2403
+ * optional parameter is what lets the fallback be expressed despite the required type.
2404
+ */
2405
+ const resolveMinMenuHeight = (minMenuHeight) => minMenuHeight ?? DEFAULT_REACT_SELECT_MIN_MENU_HEIGHT_PX;
2389
2406
  /**
2390
2407
  * A hook to retrieve components override object.
2391
2408
  * This complex object includes all the compositional components that are used in react-select. If you wish to overwrite a component, pass in an object with the appropriate namespace.
@@ -2398,7 +2415,7 @@ const getVisibleCountFromGeometries = ({ geometries, availableWidth, containerX,
2398
2415
  */
2399
2416
  const useCustomComponents = ({ disabled, readOnly, "data-testid": dataTestId, prefix, hasError, fieldSize = "medium", getOptionLabelDescription, getOptionPrefix, className, isMulti, //prefer using the component prop (ala. selectValueContainer.isMulti) inside of customComponents instead of this one.
2400
2417
  autoComplete, // see https://github.com/JedWatson/react-select/issues/758
2401
- formatOptionLabel, getMenuGeometry, }) => {
2418
+ formatOptionLabel, onMenuScrollToBottom, getMenuGeometry, setMenuListRef, }) => {
2402
2419
  const [t] = useTranslation();
2403
2420
  const { setValueContainerRef, setCounterRef, setFakeCounterRef, setGeometryRef, setMenuRef, getVisibleCount, getTotalCount, getCounterWidth, getIsComplete, } = useMultiValueOverflow({ skip: !isMulti });
2404
2421
  const interactable = useMemo(() => !Boolean(disabled) && !Boolean(readOnly), [disabled, readOnly]);
@@ -2412,6 +2429,16 @@ formatOptionLabel, getMenuGeometry, }) => {
2412
2429
  getOptionLabelDescriptionRef.current = getOptionLabelDescription;
2413
2430
  const getOptionPrefixRef = useRef(getOptionPrefix);
2414
2431
  getOptionPrefixRef.current = getOptionPrefix;
2432
+ const onMenuScrollToBottomRef = useRef(onMenuScrollToBottom);
2433
+ onMenuScrollToBottomRef.current = onMenuScrollToBottom;
2434
+ // Keep react-select's control ref intact while retaining the live DOM node for viewport-relative
2435
+ // vertical measurements that can change without changing an absolute portal's document offset.
2436
+ const controlRef = useRef(null);
2437
+ // The list keeps firing `scroll` while resting at the bottom, so only an arrival there may
2438
+ // notify. `scrollHeight` is part of the latched identity because appending a page grows the list
2439
+ // without emitting a scroll event, and a latch measured against the old height must not suppress
2440
+ // the arrival at the new bottom.
2441
+ const atBottomRef = useRef(null);
2415
2442
  const resolveOptionPrefixRef = useRef(undefined);
2416
2443
  if (resolveOptionPrefixRef.current === undefined) {
2417
2444
  resolveOptionPrefixRef.current = (data) => getOptionPrefixRef.current?.(data);
@@ -2442,7 +2469,7 @@ formatOptionLabel, getMenuGeometry, }) => {
2442
2469
  omit(selectControl.innerProps, ["onMouseDown", "onTouchEnd"])
2443
2470
  : selectControl.innerProps),
2444
2471
  ...(dataTestId ? { "data-testid": `${dataTestId}-control` } : {}),
2445
- }, children: [prefix !== undefined ? (jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-prefix` : null, children: prefix })) : null, selectControl.children, typeof disabled === "object" ? (jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-disabled-locked` : null, children: jsx(InputLockReasonTooltip, { ...disabled }) })) : null, typeof readOnly === "object" && !Boolean(disabled) ? (jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-readonly-locked` : null, children: jsx(InputLockReasonTooltip, { ...readOnly }) })) : null] }));
2472
+ }, innerRef: useMergeRefs([controlRef, selectControl.innerRef]), children: [prefix !== undefined ? (jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-prefix` : null, children: prefix })) : null, selectControl.children, typeof disabled === "object" ? (jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-disabled-locked` : null, children: jsx(InputLockReasonTooltip, { ...disabled }) })) : null, typeof readOnly === "object" && !Boolean(disabled) ? (jsx("div", { className: cvaSelectPrefixSuffix(), "data-testid": dataTestId ? `${dataTestId}-readonly-locked` : null, children: jsx(InputLockReasonTooltip, { ...readOnly }) })) : null] }));
2446
2473
  },
2447
2474
  Placeholder: selectPlaceholder => {
2448
2475
  return (jsx(components.Placeholder, { ...selectPlaceholder, className: cvaSelectPlaceholder({ className: selectPlaceholder.className }), getStyles: getNoStyles, children: selectPlaceholder.children }));
@@ -2512,10 +2539,12 @@ formatOptionLabel, getMenuGeometry, }) => {
2512
2539
  }), data: selectMultiValue.data, "data-testid": dataTestId ? `${dataTestId}-multiValue-${index}` : undefined, disabled: Boolean(disabled), fieldSize: fieldSize, getOptionPrefix: getOptionPrefixRef.current ? resolveOptionPrefixRef.current : undefined, onClose: selectMultiValue.removeProps.onClick, ref: setGeometryRef(index), removeTagLabel: t("select.multiValue.remove"), children: selectMultiValue.children }) }));
2513
2540
  },
2514
2541
  Menu: selectMenu => {
2542
+ const geometry = getMenuGeometry();
2543
+ const controlRect = controlRef.current?.getBoundingClientRect();
2515
2544
  return (jsx(components.Menu, { ...selectMenu, className: cvaSelectMenu({ className: selectMenu.className, placement: selectMenu.placement }), getStyles: getNoStyles, innerProps: {
2516
2545
  ...selectMenu.innerProps,
2517
2546
  ...(dataTestId ? { "data-testid": `${dataTestId}-menu` } : {}),
2518
- style: getMenuGeometryStyle(getMenuGeometry()),
2547
+ style: getMenuGeometryStyle(geometry, controlRect, selectMenu.placement),
2519
2548
  },
2520
2549
  // `innerRef` (not `innerProps.ref`) is the actual DOM ref react-select's `MenuPlacer` reads
2521
2550
  // to measure available space and decide whether to flip the menu open upward -- merging our
@@ -2532,23 +2561,36 @@ formatOptionLabel, getMenuGeometry, }) => {
2532
2561
  }, children: selectMenuPortal.children }));
2533
2562
  },
2534
2563
  MenuList: selectMenuList => {
2564
+ const minMenuHeight = resolveMinMenuHeight(selectMenuList.selectProps.minMenuHeight);
2535
2565
  return (jsx(components.MenuList, { className: cvaSelectMenuList({
2536
2566
  className: selectMenuList.className,
2537
2567
  }), ...selectMenuList, getStyles: getNoStyles, innerProps: {
2538
2568
  ...selectMenuList.innerProps,
2569
+ style: {
2570
+ ...selectMenuList.innerProps.style,
2571
+ // MenuPlacer may loosen its result when a later pass measures an already-constrained
2572
+ // menu. The inherited available-space bound is independent of that measured height;
2573
+ // react-select still owns which side opens and its effective minimum. Flooring only
2574
+ // the available-space term preserves that minimum as the last resort (allowing slight
2575
+ // viewport overflow) without changing normally constrained MenuPlacer heights.
2576
+ maxHeight: `min(${selectMenuList.maxHeight}px, max(var(--select-menu-available-height, 100dvh), ${minMenuHeight}px), max(40dvh, var(--spacing-64)))`,
2577
+ },
2539
2578
  onScroll: e => {
2540
2579
  const listEl = e.currentTarget;
2541
- if (listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight &&
2542
- selectMenuList.selectProps.onMenuScrollToBottom) {
2543
- if (/Firefox/.test(navigator.userAgent)) {
2544
- selectMenuList.selectProps.onMenuScrollToBottom(new WheelEvent("scroll"));
2545
- }
2546
- else {
2547
- selectMenuList.selectProps.onMenuScrollToBottom(new TouchEvent(""));
2548
- }
2580
+ const scrollHeight = listEl.scrollHeight;
2581
+ const atBottom = listEl.scrollTop + listEl.clientHeight >= scrollHeight - BOTTOM_SCROLL_TOLERANCE_PX;
2582
+ const previous = atBottomRef.current;
2583
+ const wasAtBottom = previous?.listEl === listEl && previous.atBottom && previous.scrollHeight === scrollHeight;
2584
+ atBottomRef.current = { listEl, atBottom, scrollHeight };
2585
+ if (atBottom && !wasAtBottom) {
2586
+ // Desktop Safari never defines `TouchEvent`, so constructing one there throws.
2587
+ onMenuScrollToBottomRef.current?.(new WheelEvent("scroll"));
2549
2588
  }
2550
2589
  },
2551
- }, children: selectMenuList.children }));
2590
+ },
2591
+ // Same trap as `Menu` above: `innerProps` is applied after `innerRef`, so passing a ref
2592
+ // through it would replace react-select's own list ref.
2593
+ innerRef: useMergeRefs([setMenuListRef, selectMenuList.innerRef]), children: selectMenuList.children }));
2552
2594
  },
2553
2595
  Option: selectOption => {
2554
2596
  const componentProps = {
@@ -2590,6 +2632,7 @@ formatOptionLabel, getMenuGeometry, }) => {
2590
2632
  setGeometryRef,
2591
2633
  setMenuRef,
2592
2634
  getMenuGeometry,
2635
+ setMenuListRef,
2593
2636
  ]);
2594
2637
  return customComponents;
2595
2638
  };
@@ -2597,21 +2640,30 @@ const getNoStyles = () => {
2597
2640
  // To prevent the default styles from being applied from react-select.
2598
2641
  return {};
2599
2642
  };
2600
- /**
2601
- * Translates the computed geometry into the menu's positioning/sizing inline style: grows/shrinks
2602
- * to fit content between `minWidth` (the control's own width) and `maxWidth` (the available space
2603
- * on the anchored side), flipping to right-aligned ("poking out" left of the control) when needed.
2604
- */
2605
- const getMenuGeometryStyle = (geometry) => {
2606
- if (!geometry) {
2643
+ const getMenuGeometryStyle = (geometry, controlRect, placement) => {
2644
+ const controlEdge = placement === "bottom"
2645
+ ? (controlRect?.bottom ?? geometry?.controlBottom)
2646
+ : (controlRect?.top ?? geometry?.controlTop);
2647
+ if (!geometry && controlEdge === undefined) {
2607
2648
  return undefined;
2608
2649
  }
2609
- return {
2610
- left: geometry.alignRight ? undefined : 0,
2611
- right: geometry.alignRight ? 0 : undefined,
2612
- minWidth: geometry.minWidth,
2613
- maxWidth: geometry.maxWidth,
2650
+ const style = {
2651
+ left: geometry?.alignRight === false ? 0 : undefined,
2652
+ right: geometry?.alignRight === true ? 0 : undefined,
2653
+ minWidth: geometry?.minWidth,
2654
+ maxWidth: geometry?.maxWidth,
2614
2655
  };
2656
+ // The gap the chosen side leaves between the control and the viewport edge, measured from the
2657
+ // control's own viewport coordinates (never react-select's portal `offset`, which carries the
2658
+ // document scroll for non-fixed menus). `dvh` keeps the bound live as mobile browser chrome
2659
+ // shows/hides, without duplicating the design ceiling or the flip policy in JS.
2660
+ if (controlEdge !== undefined) {
2661
+ style["--select-menu-available-height"] =
2662
+ placement === "bottom"
2663
+ ? `calc(100dvh - ${controlEdge}px - var(--spacing-1) - ${MENU_VERTICAL_BORDER_CHROME_PX}px)`
2664
+ : `calc(${controlEdge}px - var(--spacing-1) - ${MENU_VERTICAL_BORDER_CHROME_PX}px)`;
2665
+ }
2666
+ return style;
2615
2667
  };
2616
2668
  const getInputComponent = (children) => {
2617
2669
  return Array.isArray(children)
@@ -2635,7 +2687,8 @@ const getPlaceholderElement = (children) => {
2635
2687
  // dropdown keeps the same edge gap as other floating design-system elements.
2636
2688
  const EDGE_PADDING = 12;
2637
2689
  /**
2638
- * Derives the dropdown's alignment/sizing from the control's rect and the viewport:
2690
+ * Derives the dropdown's horizontal alignment/sizing from the control's rect and carries the
2691
+ * control's vertical edges for the separate viewport-height bound:
2639
2692
  * - flips to right-aligned ("poking out" left of the control) only when the control no
2640
2693
  * longer fits to the right AND flipping actually buys more room
2641
2694
  * - the width ceiling on the anchored side (bounded by the viewport edge, minus padding)
@@ -2659,6 +2712,8 @@ const computeMenuGeometry = (rect) => {
2659
2712
  alignRight,
2660
2713
  minWidth: rect.width,
2661
2714
  maxWidth: alignRight ? spaceLeft : spaceRight,
2715
+ ...(rect.top === undefined ? {} : { controlTop: rect.top }),
2716
+ ...(rect.bottom === undefined ? {} : { controlBottom: rect.bottom }),
2662
2717
  };
2663
2718
  };
2664
2719
 
@@ -2673,15 +2728,13 @@ const MENU_PLACEMENT_RECOMPUTE_DEBOUNCE_MS = 120;
2673
2728
  * (e.g. scrolling the modal/drawer/page the select sits in without closing the menu). A menu that
2674
2729
  * correctly opened "up" (or "down") can end up clipped once the layout around it moves.
2675
2730
  *
2676
- * `useSelect.ts` feeds the returned `tick` into `maxMenuHeight` -- the one prop in that dependency
2677
- * list safe to toggle purely to force a fresh measurement, with no other side effect: the
2678
- * resulting `maxHeight` react-select computes is never consumed on our end (the custom
2679
- * `Menu`/`MenuList` in useCustomComponents.tsx disable react-select's per-slot styling via
2680
- * `getStyles={getNoStyles}` and constrain height with Tailwind's `cvaMenuList` instead) -- only
2681
- * the *change* in value, not the value itself, matters. `minMenuHeight` must stay untouched:
2682
- * passing any finite number there (instead of `undefined`) would make react-select treat "the
2683
- * menu barely fits, if constrained" as true almost everywhere, short-circuiting past the actual
2684
- * flip check.
2731
+ * `useSelect.ts` uses the returned `tick` to alternate `maxMenuHeight` by one pixel around the
2732
+ * viewport height -- the one prop in that dependency list safe to toggle purely to force a fresh
2733
+ * measurement. The custom `MenuList` consumes react-select's computed result and combines it with
2734
+ * the design-system ceiling in CSS, so `maxMenuHeight` must stay a real viewport-scale height.
2735
+ * `minMenuHeight` must stay unset: leaving the wrapper prop `undefined` lets react-select apply
2736
+ * its own `defaultProps.minMenuHeight` (140px at runtime today), so the library stays the owner
2737
+ * of the flip policy and that effective value -- not this wrapper -- defines the boundary.
2685
2738
  *
2686
2739
  * Deliberately a trailing *debounce*, not a per-frame (rAF) throttle: `resize` (and, to a lesser
2687
2740
  * extent, `scroll`) fires continuously while the user is actively dragging, so a control that
@@ -2689,16 +2742,34 @@ const MENU_PLACEMENT_RECOMPUTE_DEBOUNCE_MS = 120;
2689
2742
  * flip decision -- and therefore the whole menu -- recomputed and visibly toggle top/bottom on
2690
2743
  * practically every frame of the drag. Waiting until the gesture actually settles means the menu
2691
2744
  * still only flips (at most) once, to whatever's correct for the final size/position.
2745
+ *
2746
+ * `setMenuListRef` must be wired to the rendered MenuList's DOM node so scrolling the dropdown's
2747
+ * own option list can be told apart from the surrounding layout moving.
2692
2748
  */
2693
2749
  const useMenuPlacementRecompute = () => {
2694
2750
  const [tick, setTick] = useState(0);
2695
2751
  const cleanupRef = useRef(null);
2752
+ const menuListRef = useRef(null);
2753
+ const setMenuListRef = useCallback(element => {
2754
+ menuListRef.current = element;
2755
+ }, []);
2696
2756
  const onMenuOpen = useCallback(() => {
2697
2757
  if (cleanupRef.current) {
2698
2758
  return; // already watching
2699
2759
  }
2760
+ // Refresh maxMenuHeight immediately on each arrival. Resize events are intentionally ignored
2761
+ // while closed, so without this bump a menu opened after such a resize could reuse a viewport
2762
+ // height memoized before the resize.
2763
+ setTick(previousTick => previousTick + 1);
2700
2764
  let timeoutId = null;
2701
- const handleReposition = () => {
2765
+ const handleReposition = (event) => {
2766
+ // Scrolling the dropdown's own option list doesn't move the control, and recomputing on it
2767
+ // is harmful: `MenuPlacer` measures the space below the menu *where it currently sits*, so a
2768
+ // menu already flipped up has room "below", flips down, and toggles on the next scroll.
2769
+ const target = event?.target;
2770
+ if (target instanceof Node && menuListRef.current?.contains(target) === true) {
2771
+ return;
2772
+ }
2702
2773
  if (timeoutId !== null) {
2703
2774
  clearTimeout(timeoutId);
2704
2775
  }
@@ -2726,7 +2797,7 @@ const useMenuPlacementRecompute = () => {
2726
2797
  }, []);
2727
2798
  // Stop watching if the component unmounts while the menu is still open.
2728
2799
  useEffect(() => onMenuClose, [onMenuClose]);
2729
- return useMemo(() => ({ tick, onMenuOpen, onMenuClose }), [tick, onMenuOpen, onMenuClose]);
2800
+ return useMemo(() => ({ tick, onMenuOpen, onMenuClose, setMenuListRef }), [tick, onMenuOpen, onMenuClose, setMenuListRef]);
2730
2801
  };
2731
2802
 
2732
2803
  /**
@@ -2775,6 +2846,17 @@ const useSelect = (props) => {
2775
2846
  },
2776
2847
  }), []);
2777
2848
  const getMenuGeometry = useCallback(() => menuGeometryRef.current, []);
2849
+ // Forces react-select to redo its up/down flip decision on scroll/resize while the menu stays
2850
+ // open -- see useMenuPlacementRecompute's own comment for why this is otherwise stuck at the
2851
+ // decision made when the menu was first opened. `onMenuOpen`/`onMenuClose`/`setMenuListRef` are
2852
+ // stable (useCallback with no deps), so destructuring them here doesn't affect handleMenuOpen/
2853
+ // handleMenuClose's own stability below -- only `tick` (a primitive) is expected to change.
2854
+ const { tick: menuPlacementRecomputeTick, onMenuOpen: startMenuRepositionWatcher, onMenuClose: stopMenuRepositionWatcher, setMenuListRef, } = useMenuPlacementRecompute();
2855
+ // Read on every useSelect render rather than inside the final useMemo: opening bumps the tick,
2856
+ // so a viewport resize that happened while the menu was closed cannot leave this value stale.
2857
+ // The alternating pixel only re-triggers MenuPlacer; the rendered MenuList retains the exact
2858
+ // design-system ceiling in CSS and combines it with MenuPlacer's computed result.
2859
+ const maxMenuHeight = window.innerHeight + (menuPlacementRecomputeTick % 2);
2778
2860
  const customComponents = useCustomComponents({
2779
2861
  disabled, // intentionally not evaluated as boolean, since it can be object too!
2780
2862
  readOnly: readOnly ?? false, // intentionally not evaluated as boolean, since it can be object too!
@@ -2788,7 +2870,9 @@ const useSelect = (props) => {
2788
2870
  className: stableRestProps.className,
2789
2871
  autoComplete: stableRestProps.autoComplete,
2790
2872
  formatOptionLabel,
2873
+ onMenuScrollToBottom,
2791
2874
  getMenuGeometry,
2875
+ setMenuListRef,
2792
2876
  });
2793
2877
  const interactable = useMemo(() => !Boolean(disabled) && !Boolean(readOnly), [disabled, readOnly]);
2794
2878
  // Determine the portal target for the menu
@@ -2796,12 +2880,6 @@ const useSelect = (props) => {
2796
2880
  // Use custom scroll blocking hook to prevent layout shifts
2797
2881
  // Pass the portal target so we only block scroll when menu is portaled to document.body
2798
2882
  const { blockScroll, restoreScroll } = useScrollBlock(portalTarget);
2799
- // Forces react-select to redo its up/down flip decision on scroll/resize while the menu stays
2800
- // open -- see useMenuPlacementRecompute's own comment for why this is otherwise stuck at the
2801
- // decision made when the menu was first opened. `onMenuOpen`/`onMenuClose` are stable
2802
- // (useCallback with no deps), so destructuring them here doesn't affect handleMenuOpen/
2803
- // handleMenuClose's own stability below -- only `tick` (a primitive) is expected to change.
2804
- const { tick: menuPlacementRecomputeTick, onMenuOpen: startMenuRepositionWatcher, onMenuClose: stopMenuRepositionWatcher, } = useMenuPlacementRecompute();
2805
2883
  // Store only the wrapped callbacks in refs to keep them stable
2806
2884
  // We only do this for onMenuOpen and onMenuClose because we wrap them with additional logic
2807
2885
  // Other callbacks are passed through directly and should trigger recalculation when they change
@@ -2901,16 +2979,15 @@ const useSelect = (props) => {
2901
2979
  isOptionSelected,
2902
2980
  onKeyDown,
2903
2981
  onMenuScrollToTop,
2904
- onMenuScrollToBottom,
2905
2982
  // Wire up our wrapped menu open/close handlers (with scroll blocking)
2906
2983
  onMenuOpen: handleMenuOpen,
2907
2984
  onMenuClose: handleMenuClose,
2908
2985
  // 👇 putting these here to avoid them _accidentally_ being overwritten in the future👇
2909
- // Not "controlled custom components styling" like `minMenuHeight` below -- this value is
2910
- // otherwise inert (see useMenuPlacementRecompute), it only exists to force react-select to
2911
- // redo its up/down flip decision whenever it changes.
2912
- maxMenuHeight: menuPlacementRecomputeTick,
2913
- minMenuHeight: undefined, // controlled custom components styling
2986
+ // A real viewport-scale preferred height lets MenuPlacer calculate available space. The
2987
+ // custom MenuList combines its result with the design ceiling in CSS.
2988
+ maxMenuHeight,
2989
+ // Leave unset so react-select owns its effective minimum and placement policy.
2990
+ minMenuHeight: undefined,
2914
2991
  theme: undefined,
2915
2992
  classNames: undefined,
2916
2993
  styles: menuPortalStyles,
@@ -2962,11 +3039,10 @@ const useSelect = (props) => {
2962
3039
  isOptionSelected,
2963
3040
  onKeyDown,
2964
3041
  onMenuScrollToTop,
2965
- onMenuScrollToBottom,
2966
3042
  handleMenuOpen,
2967
3043
  handleMenuClose,
2968
3044
  menuPortalStyles,
2969
- menuPlacementRecomputeTick,
3045
+ maxMenuHeight,
2970
3046
  ]);
2971
3047
  };
2972
3048
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-form-components",
3
- "version": "2.6.59",
3
+ "version": "2.6.61",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "migrations": "./migrations.json",
@@ -10,17 +10,17 @@
10
10
  "dependencies": {
11
11
  "react-calendar": "^6.0.0",
12
12
  "react-select": "^5.10.2",
13
- "@trackunit/date-and-time-utils": "1.14.38",
13
+ "@trackunit/date-and-time-utils": "1.14.40",
14
14
  "usehooks-ts": "^3.1.0",
15
15
  "libphonenumber-js": "^1.13.11",
16
16
  "zod": "^3.25.76",
17
17
  "tailwind-merge": "^2.0.0",
18
- "@trackunit/css-class-variance-utilities": "2.0.7",
19
- "@trackunit/react-components": "2.13.11",
20
- "@trackunit/ui-icons": "1.14.35",
21
- "@trackunit/shared-utils": "1.16.42",
22
- "@trackunit/ui-design-tokens": "1.15.26",
23
- "@trackunit/i18n-library-translation": "2.4.51",
18
+ "@trackunit/css-class-variance-utilities": "2.0.9",
19
+ "@trackunit/react-components": "2.13.13",
20
+ "@trackunit/ui-icons": "1.14.37",
21
+ "@trackunit/shared-utils": "1.16.44",
22
+ "@trackunit/ui-design-tokens": "1.15.28",
23
+ "@trackunit/i18n-library-translation": "2.4.53",
24
24
  "string-ts": "^2.0.0",
25
25
  "es-toolkit": "^1.39.10"
26
26
  },
@@ -3,7 +3,7 @@
3
3
  * Maybe we can try to refactor this in the future?
4
4
  */
5
5
  import { CommonProps } from "@trackunit/react-components";
6
- import { ReactNode } from "react";
6
+ import { ReactNode, RefCallback } from "react";
7
7
  import { type FormatOptionLabelMeta, type SelectComponentsConfig, GroupBase } from "react-select";
8
8
  import { FormComponentSizes } from "../../types";
9
9
  import { LockedForReasons } from "../BaseInput/InputLockReasonTooltip";
@@ -21,6 +21,7 @@ interface CustomComponentsProps<TOption> extends CommonProps {
21
21
  isMulti: boolean;
22
22
  autoComplete?: "off" | "on" | "no";
23
23
  formatOptionLabel?: (data: TOption, meta: FormatOptionLabelMeta<TOption>) => ReactNode;
24
+ onMenuScrollToBottom?: (event: WheelEvent | TouchEvent) => void;
24
25
  /**
25
26
  * Returns the latest edge-aware menu geometry (alignment + min/max-width), computed from the
26
27
  * control's rect via `styles.menuPortal` in useSelect.ts. Read directly here (not through
@@ -28,6 +29,8 @@ interface CustomComponentsProps<TOption> extends CommonProps {
28
29
  * in favor of Tailwind classes) and applied as inline styles on the actual menu element.
29
30
  */
30
31
  getMenuGeometry: () => MenuGeometry | null;
32
+ /** Registers the rendered MenuList's DOM node with `useMenuPlacementRecompute`. */
33
+ setMenuListRef?: RefCallback<HTMLDivElement>;
31
34
  }
32
35
  /**
33
36
  * A hook to retrieve components override object.
@@ -39,5 +42,5 @@ interface CustomComponentsProps<TOption> extends CommonProps {
39
42
  * @param {CustomComponentsProps<TOption>} props - The custom components props
40
43
  * @returns {SelectComponentsConfig<TOption, TIsMulti, TGroup>} components object to override react-select default components
41
44
  */
42
- export declare const useCustomComponents: <TOption, TIsMulti extends boolean = false, TGroup extends GroupBase<TOption> = GroupBase<TOption>>({ disabled, readOnly, "data-testid": dataTestId, prefix, hasError, fieldSize, getOptionLabelDescription, getOptionPrefix, className, isMulti, autoComplete, formatOptionLabel, getMenuGeometry, }: CustomComponentsProps<TOption>) => SelectComponentsConfig<TOption, TIsMulti, TGroup>;
45
+ export declare const useCustomComponents: <TOption, TIsMulti extends boolean = false, TGroup extends GroupBase<TOption> = GroupBase<TOption>>({ disabled, readOnly, "data-testid": dataTestId, prefix, hasError, fieldSize, getOptionLabelDescription, getOptionPrefix, className, isMulti, autoComplete, formatOptionLabel, onMenuScrollToBottom, getMenuGeometry, setMenuListRef, }: CustomComponentsProps<TOption>) => SelectComponentsConfig<TOption, TIsMulti, TGroup>;
43
46
  export {};
@@ -1,13 +1,26 @@
1
1
  import { GroupBase, StylesConfig } from "react-select";
2
2
  type MenuPortalStyleFn = NonNullable<StylesConfig<unknown, boolean, GroupBase<unknown>>["menuPortal"]>;
3
3
  type PortalStyleArgs = Parameters<MenuPortalStyleFn>[1];
4
+ type ControlRect = PortalStyleArgs["rect"] & Partial<{
5
+ top: number;
6
+ bottom: number;
7
+ }>;
4
8
  export interface MenuGeometry {
5
9
  alignRight: boolean;
6
10
  minWidth: number;
7
11
  maxWidth: number;
12
+ /**
13
+ * The control's own edges in viewport coordinates, used only to bound the menu's height once
14
+ * react-select has chosen a placement. Deliberately not react-select's sibling `offset`: that one
15
+ * adds `window.pageYOffset` for any non-fixed menu, which is a document coordinate and would
16
+ * bound the menu against the wrong origin as soon as the page is scrolled.
17
+ */
18
+ controlTop?: number;
19
+ controlBottom?: number;
8
20
  }
9
21
  /**
10
- * Derives the dropdown's alignment/sizing from the control's rect and the viewport:
22
+ * Derives the dropdown's horizontal alignment/sizing from the control's rect and carries the
23
+ * control's vertical edges for the separate viewport-height bound:
11
24
  * - flips to right-aligned ("poking out" left of the control) only when the control no
12
25
  * longer fits to the right AND flipping actually buys more room
13
26
  * - the width ceiling on the anchored side (bounded by the viewport edge, minus padding)
@@ -22,5 +35,5 @@ export interface MenuGeometry {
22
35
  * contribute to its shrink-to-fit sizing, so styling the portal can't drive the visible menu's
23
36
  * width or position.
24
37
  */
25
- export declare const computeMenuGeometry: (rect: PortalStyleArgs["rect"]) => MenuGeometry;
38
+ export declare const computeMenuGeometry: (rect: ControlRect) => MenuGeometry;
26
39
  export {};
@@ -1,3 +1,4 @@
1
+ import { RefCallback } from "react";
1
2
  export declare const MENU_PLACEMENT_RECOMPUTE_DEBOUNCE_MS = 120;
2
3
  /**
3
4
  * react-select's own `MenuPlacer` (internal to the library) decides the escaped dropdown's
@@ -7,15 +8,13 @@ export declare const MENU_PLACEMENT_RECOMPUTE_DEBOUNCE_MS = 120;
7
8
  * (e.g. scrolling the modal/drawer/page the select sits in without closing the menu). A menu that
8
9
  * correctly opened "up" (or "down") can end up clipped once the layout around it moves.
9
10
  *
10
- * `useSelect.ts` feeds the returned `tick` into `maxMenuHeight` -- the one prop in that dependency
11
- * list safe to toggle purely to force a fresh measurement, with no other side effect: the
12
- * resulting `maxHeight` react-select computes is never consumed on our end (the custom
13
- * `Menu`/`MenuList` in useCustomComponents.tsx disable react-select's per-slot styling via
14
- * `getStyles={getNoStyles}` and constrain height with Tailwind's `cvaMenuList` instead) -- only
15
- * the *change* in value, not the value itself, matters. `minMenuHeight` must stay untouched:
16
- * passing any finite number there (instead of `undefined`) would make react-select treat "the
17
- * menu barely fits, if constrained" as true almost everywhere, short-circuiting past the actual
18
- * flip check.
11
+ * `useSelect.ts` uses the returned `tick` to alternate `maxMenuHeight` by one pixel around the
12
+ * viewport height -- the one prop in that dependency list safe to toggle purely to force a fresh
13
+ * measurement. The custom `MenuList` consumes react-select's computed result and combines it with
14
+ * the design-system ceiling in CSS, so `maxMenuHeight` must stay a real viewport-scale height.
15
+ * `minMenuHeight` must stay unset: leaving the wrapper prop `undefined` lets react-select apply
16
+ * its own `defaultProps.minMenuHeight` (140px at runtime today), so the library stays the owner
17
+ * of the flip policy and that effective value -- not this wrapper -- defines the boundary.
19
18
  *
20
19
  * Deliberately a trailing *debounce*, not a per-frame (rAF) throttle: `resize` (and, to a lesser
21
20
  * extent, `scroll`) fires continuously while the user is actively dragging, so a control that
@@ -23,9 +22,13 @@ export declare const MENU_PLACEMENT_RECOMPUTE_DEBOUNCE_MS = 120;
23
22
  * flip decision -- and therefore the whole menu -- recomputed and visibly toggle top/bottom on
24
23
  * practically every frame of the drag. Waiting until the gesture actually settles means the menu
25
24
  * still only flips (at most) once, to whatever's correct for the final size/position.
25
+ *
26
+ * `setMenuListRef` must be wired to the rendered MenuList's DOM node so scrolling the dropdown's
27
+ * own option list can be told apart from the surrounding layout moving.
26
28
  */
27
29
  export declare const useMenuPlacementRecompute: () => {
28
30
  tick: number;
29
31
  onMenuOpen: () => void;
30
32
  onMenuClose: () => void;
33
+ setMenuListRef: RefCallback<HTMLElement>;
31
34
  };