• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

JedWatson / react-select / 836eec20-e78b-418a-bfab-0939170425c8

24 Jan 2025 03:40PM UTC coverage: 75.844%. Remained the same
836eec20-e78b-418a-bfab-0939170425c8

Pull #5993

circleci

web-flow
Update wild-seahorses-fix.md
Pull Request #5993: exported FilterOptionOption

658 of 1052 branches covered (62.55%)

1033 of 1362 relevant lines covered (75.84%)

1934.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

34.38
/packages/react-select/src/components/Menu.tsx
1
/** @jsx jsx */
2
import {
3
  createContext,
4
  JSX,
5
  ReactElement,
6
  ReactNode,
7
  Ref,
8
  useCallback,
9
  useContext,
10
  useMemo,
11
  useRef,
12
  useState,
13
} from 'react';
14
import { jsx } from '@emotion/react';
15
import { createPortal } from 'react-dom';
16
import { autoUpdate } from '@floating-ui/dom';
17
import useLayoutEffect from 'use-isomorphic-layout-effect';
18

19
import {
20
  animatedScrollTo,
21
  getBoundingClientObj,
22
  getScrollParent,
23
  getScrollTop,
24
  getStyleProps,
25
  normalizedHeight,
26
  scrollTo,
27
} from '../utils';
28
import {
29
  MenuPlacement,
30
  MenuPosition,
31
  CommonProps,
32
  GroupBase,
33
  CommonPropsAndClassName,
34
  CoercedMenuPlacement,
35
  CSSObjectWithLabel,
36
} from '../types';
37

38
// ==============================
39
// Menu
40
// ==============================
41

42
// Get Menu Placement
43
// ------------------------------
44

45
interface CalculatedMenuPlacementAndHeight {
46
  placement: CoercedMenuPlacement;
47
  maxHeight: number;
48
}
49
interface PlacementArgs {
50
  maxHeight: number;
51
  menuEl: HTMLDivElement | null;
52
  minHeight: number;
53
  placement: MenuPlacement;
54
  shouldScroll: boolean;
55
  isFixedPosition: boolean;
56
  controlHeight: number;
57
}
58

59
export function getMenuPlacement({
60
  maxHeight: preferredMaxHeight,
61
  menuEl,
62
  minHeight,
63
  placement: preferredPlacement,
64
  shouldScroll,
65
  isFixedPosition,
66
  controlHeight,
67
}: PlacementArgs): CalculatedMenuPlacementAndHeight {
68
  const scrollParent = getScrollParent(menuEl!);
169✔
69
  const defaultState: CalculatedMenuPlacementAndHeight = {
169✔
70
    placement: 'bottom',
71
    maxHeight: preferredMaxHeight,
72
  };
73

74
  // something went wrong, return default state
75
  if (!menuEl || !menuEl.offsetParent) return defaultState;
169!
76

77
  // we can't trust `scrollParent.scrollHeight` --> it may increase when
78
  // the menu is rendered
79
  const { height: scrollHeight } = scrollParent.getBoundingClientRect();
×
80
  const {
81
    bottom: menuBottom,
82
    height: menuHeight,
83
    top: menuTop,
84
  } = menuEl.getBoundingClientRect();
×
85

86
  const { top: containerTop } = menuEl.offsetParent.getBoundingClientRect();
×
87
  const viewHeight = isFixedPosition
×
88
    ? window.innerHeight
89
    : normalizedHeight(scrollParent);
90
  const scrollTop = getScrollTop(scrollParent);
×
91

92
  const marginBottom = parseInt(getComputedStyle(menuEl).marginBottom, 10);
×
93
  const marginTop = parseInt(getComputedStyle(menuEl).marginTop, 10);
×
94
  const viewSpaceAbove = containerTop - marginTop;
×
95
  const viewSpaceBelow = viewHeight - menuTop;
×
96
  const scrollSpaceAbove = viewSpaceAbove + scrollTop;
×
97
  const scrollSpaceBelow = scrollHeight - scrollTop - menuTop;
×
98

99
  const scrollDown = menuBottom - viewHeight + scrollTop + marginBottom;
×
100
  const scrollUp = scrollTop + menuTop - marginTop;
×
101
  const scrollDuration = 160;
×
102

103
  switch (preferredPlacement) {
×
104
    case 'auto':
105
    case 'bottom':
106
      // 1: the menu will fit, do nothing
107
      if (viewSpaceBelow >= menuHeight) {
×
108
        return { placement: 'bottom', maxHeight: preferredMaxHeight };
×
109
      }
110

111
      // 2: the menu will fit, if scrolled
112
      if (scrollSpaceBelow >= menuHeight && !isFixedPosition) {
×
113
        if (shouldScroll) {
×
114
          animatedScrollTo(scrollParent, scrollDown, scrollDuration);
×
115
        }
116

117
        return { placement: 'bottom', maxHeight: preferredMaxHeight };
×
118
      }
119

120
      // 3: the menu will fit, if constrained
121
      if (
×
122
        (!isFixedPosition && scrollSpaceBelow >= minHeight) ||
×
123
        (isFixedPosition && viewSpaceBelow >= minHeight)
124
      ) {
125
        if (shouldScroll) {
×
126
          animatedScrollTo(scrollParent, scrollDown, scrollDuration);
×
127
        }
128

129
        // we want to provide as much of the menu as possible to the user,
130
        // so give them whatever is available below rather than the minHeight.
131
        const constrainedHeight = isFixedPosition
×
132
          ? viewSpaceBelow - marginBottom
133
          : scrollSpaceBelow - marginBottom;
134

135
        return {
×
136
          placement: 'bottom',
137
          maxHeight: constrainedHeight,
138
        };
139
      }
140

141
      // 4. Forked beviour when there isn't enough space below
142

143
      // AUTO: flip the menu, render above
144
      if (preferredPlacement === 'auto' || isFixedPosition) {
×
145
        // may need to be constrained after flipping
146
        let constrainedHeight = preferredMaxHeight;
×
147
        const spaceAbove = isFixedPosition ? viewSpaceAbove : scrollSpaceAbove;
×
148

149
        if (spaceAbove >= minHeight) {
×
150
          constrainedHeight = Math.min(
×
151
            spaceAbove - marginBottom - controlHeight,
152
            preferredMaxHeight
153
          );
154
        }
155

156
        return { placement: 'top', maxHeight: constrainedHeight };
×
157
      }
158

159
      // BOTTOM: allow browser to increase scrollable area and immediately set scroll
160
      if (preferredPlacement === 'bottom') {
×
161
        if (shouldScroll) {
×
162
          scrollTo(scrollParent, scrollDown);
×
163
        }
164
        return { placement: 'bottom', maxHeight: preferredMaxHeight };
×
165
      }
166
      break;
×
167
    case 'top':
168
      // 1: the menu will fit, do nothing
169
      if (viewSpaceAbove >= menuHeight) {
×
170
        return { placement: 'top', maxHeight: preferredMaxHeight };
×
171
      }
172

173
      // 2: the menu will fit, if scrolled
174
      if (scrollSpaceAbove >= menuHeight && !isFixedPosition) {
×
175
        if (shouldScroll) {
×
176
          animatedScrollTo(scrollParent, scrollUp, scrollDuration);
×
177
        }
178

179
        return { placement: 'top', maxHeight: preferredMaxHeight };
×
180
      }
181

182
      // 3: the menu will fit, if constrained
183
      if (
×
184
        (!isFixedPosition && scrollSpaceAbove >= minHeight) ||
×
185
        (isFixedPosition && viewSpaceAbove >= minHeight)
186
      ) {
187
        let constrainedHeight = preferredMaxHeight;
×
188

189
        // we want to provide as much of the menu as possible to the user,
190
        // so give them whatever is available below rather than the minHeight.
191
        if (
×
192
          (!isFixedPosition && scrollSpaceAbove >= minHeight) ||
×
193
          (isFixedPosition && viewSpaceAbove >= minHeight)
194
        ) {
195
          constrainedHeight = isFixedPosition
×
196
            ? viewSpaceAbove - marginTop
197
            : scrollSpaceAbove - marginTop;
198
        }
199

200
        if (shouldScroll) {
×
201
          animatedScrollTo(scrollParent, scrollUp, scrollDuration);
×
202
        }
203

204
        return {
×
205
          placement: 'top',
206
          maxHeight: constrainedHeight,
207
        };
208
      }
209

210
      // 4. not enough space, the browser WILL NOT increase scrollable area when
211
      // absolutely positioned element rendered above the viewport (only below).
212
      // Flip the menu, render below
213
      return { placement: 'bottom', maxHeight: preferredMaxHeight };
×
214
    default:
215
      throw new Error(`Invalid placement provided "${preferredPlacement}".`);
×
216
  }
217

218
  return defaultState;
×
219
}
220

221
// Menu Component
222
// ------------------------------
223

224
export interface MenuPlacementProps {
225
  /** Set the minimum height of the menu. */
226
  minMenuHeight: number;
227
  /** Set the maximum height of the menu. */
228
  maxMenuHeight: number;
229
  /** Set whether the menu should be at the top, at the bottom. The auto options sets it to bottom. */
230
  menuPlacement: MenuPlacement;
231
  /** The CSS position value of the menu, when "fixed" extra layout management is required */
232
  menuPosition: MenuPosition;
233
  /** Set whether the page should scroll to show the menu. */
234
  menuShouldScrollIntoView: boolean;
235
}
236

237
export interface MenuProps<
238
  Option = unknown,
239
  IsMulti extends boolean = boolean,
240
  Group extends GroupBase<Option> = GroupBase<Option>
241
> extends CommonPropsAndClassName<Option, IsMulti, Group>,
242
    MenuPlacementProps {
243
  /** Reference to the internal element, consumed by the MenuPlacer component */
244
  innerRef: Ref<HTMLDivElement>;
245
  innerProps: JSX.IntrinsicElements['div'];
246
  isLoading: boolean;
247
  placement: CoercedMenuPlacement;
248
  /** The children to be rendered. */
249
  children: ReactNode;
250
}
251

252
interface PlacerProps {
253
  placement: CoercedMenuPlacement;
254
  maxHeight: number;
255
}
256

257
interface ChildrenProps {
258
  ref: Ref<HTMLDivElement>;
259
  placerProps: PlacerProps;
260
}
261

262
export interface MenuPlacerProps<
263
  Option,
264
  IsMulti extends boolean,
265
  Group extends GroupBase<Option>
266
> extends CommonProps<Option, IsMulti, Group>,
267
    MenuPlacementProps {
268
  /** The children to be rendered. */
269
  children: (childrenProps: ChildrenProps) => ReactElement;
270
}
271

272
function alignToControl(placement: CoercedMenuPlacement) {
273
  const placementToCSSProp = { bottom: 'top', top: 'bottom' };
768✔
274
  return placement ? placementToCSSProp[placement] : 'bottom';
768!
275
}
276
const coercePlacement = (p: MenuPlacement) => (p === 'auto' ? 'bottom' : p);
170!
277

278
export const menuCSS = <
5✔
279
  Option,
280
  IsMulti extends boolean,
281
  Group extends GroupBase<Option>
282
>(
283
  {
284
    placement,
285
    theme: { borderRadius, spacing, colors },
286
  }: MenuProps<Option, IsMulti, Group>,
287
  unstyled: boolean
288
): CSSObjectWithLabel => ({
768✔
289
  label: 'menu',
290
  [alignToControl(placement)]: '100%',
291
  position: 'absolute',
292
  width: '100%',
293
  zIndex: 1,
294
  ...(unstyled
768!
295
    ? {}
296
    : {
297
        backgroundColor: colors.neutral0,
298
        borderRadius: borderRadius,
299
        boxShadow:
300
          '0 0 0 1px hsla(0, 0%, 0%, 0.1), 0 4px 11px hsla(0, 0%, 0%, 0.1)',
301
        marginBottom: spacing.menuGutter,
302
        marginTop: spacing.menuGutter,
303
      }),
304
});
305

306
const PortalPlacementContext =
307
  createContext<{
5✔
308
    setPortalPlacement: (placement: CoercedMenuPlacement) => void;
309
  } | null>(null);
310

311
// NOTE: internal only
312
export const MenuPlacer = <
5✔
313
  Option,
314
  IsMulti extends boolean,
315
  Group extends GroupBase<Option>
316
>(
317
  props: MenuPlacerProps<Option, IsMulti, Group>
318
) => {
319
  const {
320
    children,
321
    minMenuHeight,
322
    maxMenuHeight,
323
    menuPlacement,
324
    menuPosition,
325
    menuShouldScrollIntoView,
326
    theme,
327
  } = props;
769✔
328

329
  const { setPortalPlacement } = useContext(PortalPlacementContext) || {};
769✔
330
  const ref = useRef<HTMLDivElement | null>(null);
769✔
331
  const [maxHeight, setMaxHeight] = useState(maxMenuHeight);
769✔
332
  const [placement, setPlacement] = useState<CoercedMenuPlacement | null>(null);
769✔
333
  const { controlHeight } = theme.spacing;
769✔
334

335
  useLayoutEffect(() => {
769✔
336
    const menuEl = ref.current;
170✔
337
    if (!menuEl) return;
170✔
338

339
    // DO NOT scroll if position is fixed
340
    const isFixedPosition = menuPosition === 'fixed';
169✔
341
    const shouldScroll = menuShouldScrollIntoView && !isFixedPosition;
169✔
342

343
    const state = getMenuPlacement({
169✔
344
      maxHeight: maxMenuHeight,
345
      menuEl,
346
      minHeight: minMenuHeight,
347
      placement: menuPlacement,
348
      shouldScroll,
349
      isFixedPosition,
350
      controlHeight,
351
    });
352

353
    setMaxHeight(state.maxHeight);
169✔
354
    setPlacement(state.placement);
169✔
355
    setPortalPlacement?.(state.placement);
169✔
356
  }, [
357
    maxMenuHeight,
358
    menuPlacement,
359
    menuPosition,
360
    menuShouldScrollIntoView,
361
    minMenuHeight,
362
    setPortalPlacement,
363
    controlHeight,
364
  ]);
365

366
  return children({
769✔
367
    ref,
368
    placerProps: {
369
      ...props,
370
      placement: placement || coercePlacement(menuPlacement),
939✔
371
      maxHeight,
372
    },
373
  });
374
};
375

376
const Menu = <Option, IsMulti extends boolean, Group extends GroupBase<Option>>(
5✔
377
  props: MenuProps<Option, IsMulti, Group>
378
) => {
379
  const { children, innerRef, innerProps } = props;
768✔
380
  return (
768✔
381
    <div
382
      {...getStyleProps(props, 'menu', { menu: true })}
383
      ref={innerRef}
384
      {...innerProps}
385
    >
386
      {children}
387
    </div>
388
  );
389
};
390

391
export default Menu;
392

393
// ==============================
394
// Menu List
395
// ==============================
396

397
export interface MenuListProps<
398
  Option = unknown,
399
  IsMulti extends boolean = boolean,
400
  Group extends GroupBase<Option> = GroupBase<Option>
401
> extends CommonPropsAndClassName<Option, IsMulti, Group> {
402
  /** Set the max height of the Menu component  */
403
  maxHeight: number;
404
  /** The children to be rendered. */
405
  children: ReactNode;
406
  /** Inner ref to DOM ReactNode */
407
  innerRef: Ref<HTMLDivElement>;
408
  /** The currently focused option */
409
  focusedOption: Option;
410
  /** Props to be passed to the menu-list wrapper. */
411
  innerProps: JSX.IntrinsicElements['div'];
412
}
413
export const menuListCSS = <
5✔
414
  Option,
415
  IsMulti extends boolean,
416
  Group extends GroupBase<Option>
417
>(
418
  {
419
    maxHeight,
420
    theme: {
421
      spacing: { baseUnit },
422
    },
423
  }: MenuListProps<Option, IsMulti, Group>,
424
  unstyled: boolean
425
): CSSObjectWithLabel => ({
768✔
426
  maxHeight,
427
  overflowY: 'auto',
428
  position: 'relative', // required for offset[Height, Top] > keyboard scroll
429
  WebkitOverflowScrolling: 'touch',
430
  ...(unstyled
768!
431
    ? {}
432
    : {
433
        paddingBottom: baseUnit,
434
        paddingTop: baseUnit,
435
      }),
436
});
437
export const MenuList = <
5✔
438
  Option,
439
  IsMulti extends boolean,
440
  Group extends GroupBase<Option>
441
>(
442
  props: MenuListProps<Option, IsMulti, Group>
443
) => {
444
  const { children, innerProps, innerRef, isMulti } = props;
768✔
445
  return (
768✔
446
    <div
447
      {...getStyleProps(props, 'menuList', {
448
        'menu-list': true,
449
        'menu-list--is-multi': isMulti,
450
      })}
451
      ref={innerRef}
452
      {...innerProps}
453
    >
454
      {children}
455
    </div>
456
  );
457
};
458

459
// ==============================
460
// Menu Notices
461
// ==============================
462

463
const noticeCSS = <
5✔
464
  Option,
465
  IsMulti extends boolean,
466
  Group extends GroupBase<Option>
467
>(
468
  {
469
    theme: {
470
      spacing: { baseUnit },
471
      colors,
472
    },
473
  }: NoticeProps<Option, IsMulti, Group>,
474
  unstyled: boolean
475
): CSSObjectWithLabel => ({
38✔
476
  textAlign: 'center',
477
  ...(unstyled
38!
478
    ? {}
479
    : {
480
        color: colors.neutral40,
481
        padding: `${baseUnit * 2}px ${baseUnit * 3}px`,
482
      }),
483
});
484
export const noOptionsMessageCSS = noticeCSS;
5✔
485
export const loadingMessageCSS = noticeCSS;
5✔
486

487
export interface NoticeProps<
488
  Option = unknown,
489
  IsMulti extends boolean = boolean,
490
  Group extends GroupBase<Option> = GroupBase<Option>
491
> extends CommonPropsAndClassName<Option, IsMulti, Group> {
492
  /** The children to be rendered. */
493
  children: ReactNode;
494
  /** Props to be passed on to the wrapper. */
495
  innerProps: JSX.IntrinsicElements['div'];
496
}
497

498
export const NoOptionsMessage = <
5✔
499
  Option,
500
  IsMulti extends boolean,
501
  Group extends GroupBase<Option>
502
>({
503
  children = 'No options',
×
504
  innerProps,
505
  ...restProps
506
}: NoticeProps<Option, IsMulti, Group>) => {
507
  return (
30✔
508
    <div
509
      {...getStyleProps(
510
        { ...restProps, children, innerProps },
511
        'noOptionsMessage',
512
        {
513
          'menu-notice': true,
514
          'menu-notice--no-options': true,
515
        }
516
      )}
517
      {...innerProps}
518
    >
519
      {children}
520
    </div>
521
  );
522
};
523

524
export const LoadingMessage = <
5✔
525
  Option,
526
  IsMulti extends boolean,
527
  Group extends GroupBase<Option>
528
>({
529
  children = 'Loading...',
×
530
  innerProps,
531
  ...restProps
532
}: NoticeProps<Option, IsMulti, Group>) => {
533
  return (
8✔
534
    <div
535
      {...getStyleProps(
536
        { ...restProps, children, innerProps },
537
        'loadingMessage',
538
        {
539
          'menu-notice': true,
540
          'menu-notice--loading': true,
541
        }
542
      )}
543
      {...innerProps}
544
    >
545
      {children}
546
    </div>
547
  );
548
};
549

550
// ==============================
551
// Menu Portal
552
// ==============================
553

554
export interface MenuPortalProps<
555
  Option,
556
  IsMulti extends boolean,
557
  Group extends GroupBase<Option>
558
> extends CommonPropsAndClassName<Option, IsMulti, Group> {
559
  appendTo: HTMLElement | undefined;
560
  children: ReactNode; // ideally Menu<MenuProps>
561
  controlElement: HTMLDivElement | null;
562
  innerProps: JSX.IntrinsicElements['div'];
563
  menuPlacement: MenuPlacement;
564
  menuPosition: MenuPosition;
565
}
566

567
export interface PortalStyleArgs {
568
  offset: number;
569
  position: MenuPosition;
570
  rect: { left: number; width: number };
571
}
572

573
export const menuPortalCSS = ({
5✔
574
  rect,
575
  offset,
576
  position,
577
}: PortalStyleArgs): CSSObjectWithLabel => ({
×
578
  left: rect.left,
579
  position: position,
580
  top: offset,
581
  width: rect.width,
582
  zIndex: 1,
583
});
584

585
interface ComputedPosition {
586
  offset: number;
587
  rect: { left: number; width: number };
588
}
589

590
export const MenuPortal = <
5✔
591
  Option,
592
  IsMulti extends boolean,
593
  Group extends GroupBase<Option>
594
>(
595
  props: MenuPortalProps<Option, IsMulti, Group>
596
) => {
597
  const {
598
    appendTo,
599
    children,
600
    controlElement,
601
    innerProps,
602
    menuPlacement,
603
    menuPosition,
604
  } = props;
×
605

606
  const menuPortalRef = useRef<HTMLDivElement | null>(null);
×
607
  const cleanupRef = useRef<(() => void) | void | null>(null);
×
608

609
  const [placement, setPortalPlacement] = useState<'bottom' | 'top'>(
×
610
    coercePlacement(menuPlacement)
611
  );
612
  const portalPlacementContext = useMemo(
×
613
    () => ({
×
614
      setPortalPlacement,
615
    }),
616
    []
617
  );
618
  const [computedPosition, setComputedPosition] =
619
    useState<ComputedPosition | null>(null);
×
620

621
  const updateComputedPosition = useCallback(() => {
×
622
    if (!controlElement) return;
×
623

624
    const rect = getBoundingClientObj(controlElement);
×
625
    const scrollDistance = menuPosition === 'fixed' ? 0 : window.pageYOffset;
×
626
    const offset = rect[placement] + scrollDistance;
×
627
    if (
×
628
      offset !== computedPosition?.offset ||
×
629
      rect.left !== computedPosition?.rect.left ||
630
      rect.width !== computedPosition?.rect.width
631
    ) {
632
      setComputedPosition({ offset, rect });
×
633
    }
634
  }, [
635
    controlElement,
636
    menuPosition,
637
    placement,
638
    computedPosition?.offset,
639
    computedPosition?.rect.left,
640
    computedPosition?.rect.width,
641
  ]);
642

643
  useLayoutEffect(() => {
×
644
    updateComputedPosition();
×
645
  }, [updateComputedPosition]);
646

647
  const runAutoUpdate = useCallback(() => {
×
648
    if (typeof cleanupRef.current === 'function') {
×
649
      cleanupRef.current();
×
650
      cleanupRef.current = null;
×
651
    }
652

653
    if (controlElement && menuPortalRef.current) {
×
654
      cleanupRef.current = autoUpdate(
×
655
        controlElement,
656
        menuPortalRef.current,
657
        updateComputedPosition,
658
        { elementResize: 'ResizeObserver' in window }
659
      );
660
    }
661
  }, [controlElement, updateComputedPosition]);
662

663
  useLayoutEffect(() => {
×
664
    runAutoUpdate();
×
665
  }, [runAutoUpdate]);
666

667
  const setMenuPortalElement = useCallback(
×
668
    (menuPortalElement: HTMLDivElement) => {
669
      menuPortalRef.current = menuPortalElement;
×
670
      runAutoUpdate();
×
671
    },
672
    [runAutoUpdate]
673
  );
674

675
  // bail early if required elements aren't present
676
  if ((!appendTo && menuPosition !== 'fixed') || !computedPosition) return null;
×
677

678
  // same wrapper element whether fixed or portalled
679
  const menuWrapper = (
680
    <div
×
681
      ref={setMenuPortalElement}
682
      {...getStyleProps(
683
        {
684
          ...props,
685
          offset: computedPosition.offset,
686
          position: menuPosition,
687
          rect: computedPosition.rect,
688
        },
689
        'menuPortal',
690
        {
691
          'menu-portal': true,
692
        }
693
      )}
694
      {...innerProps}
695
    >
696
      {children}
697
    </div>
698
  );
699

700
  return (
×
701
    <PortalPlacementContext.Provider value={portalPlacementContext}>
702
      {appendTo ? createPortal(menuWrapper, appendTo) : menuWrapper}
×
703
    </PortalPlacementContext.Provider>
704
  );
705
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc