@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 +127 -51
- package/index.esm.js +127 -51
- package/package.json +8 -8
- package/src/components/BaseSelect/useCustomComponents.d.ts +5 -2
- package/src/components/BaseSelect/utils/computeMenuGeometry.d.ts +15 -2
- package/src/components/BaseSelect/utils/useMenuPlacementRecompute.d.ts +12 -9
package/index.cjs.js
CHANGED
|
@@ -1653,7 +1653,10 @@ const cvaSelectMenu = cssClassVarianceUtilities.cva({
|
|
|
1653
1653
|
placement: "bottom",
|
|
1654
1654
|
},
|
|
1655
1655
|
});
|
|
1656
|
-
|
|
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(
|
|
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
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
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
|
-
|
|
2611
|
-
left: geometry
|
|
2612
|
-
right: geometry
|
|
2613
|
-
minWidth: geometry
|
|
2614
|
-
maxWidth: geometry
|
|
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
|
|
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`
|
|
2678
|
-
* list safe to toggle purely to force a fresh
|
|
2679
|
-
*
|
|
2680
|
-
* `
|
|
2681
|
-
* `
|
|
2682
|
-
*
|
|
2683
|
-
*
|
|
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
|
-
//
|
|
2911
|
-
//
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
minMenuHeight: undefined,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
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
|
-
|
|
2610
|
-
left: geometry
|
|
2611
|
-
right: geometry
|
|
2612
|
-
minWidth: geometry
|
|
2613
|
-
maxWidth: geometry
|
|
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
|
|
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`
|
|
2677
|
-
* list safe to toggle purely to force a fresh
|
|
2678
|
-
*
|
|
2679
|
-
* `
|
|
2680
|
-
* `
|
|
2681
|
-
*
|
|
2682
|
-
*
|
|
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
|
-
//
|
|
2910
|
-
//
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
minMenuHeight: undefined,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
19
|
-
"@trackunit/react-components": "2.13.
|
|
20
|
-
"@trackunit/ui-icons": "1.14.
|
|
21
|
-
"@trackunit/shared-utils": "1.16.
|
|
22
|
-
"@trackunit/ui-design-tokens": "1.15.
|
|
23
|
-
"@trackunit/i18n-library-translation": "2.4.
|
|
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
|
|
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:
|
|
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`
|
|
11
|
-
* list safe to toggle purely to force a fresh
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
};
|