/
SelectMenu.tsx
138 lines (119 loc) · 5.1 KB
/
SelectMenu.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import { cx } from '@emotion/css';
import { max } from 'lodash';
import React, { RefCallback } from 'react';
import { MenuListProps } from 'react-select';
import { FixedSizeList as List } from 'react-window';
import { SelectableValue, toIconName } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Icon } from '../Icon/Icon';
import { getSelectStyles } from './getSelectStyles';
interface SelectMenuProps {
maxHeight: number;
innerRef: RefCallback<HTMLDivElement>;
innerProps: {};
}
export const SelectMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren<SelectMenuProps>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
return (
<div {...innerProps} className={styles.menu} style={{ maxHeight }} aria-label="Select options menu">
<CustomScrollbar scrollRefCallback={innerRef} autoHide={false} autoHeightMax="inherit" hideHorizontalTrack>
{children}
</CustomScrollbar>
</div>
);
};
SelectMenu.displayName = 'SelectMenu';
const VIRTUAL_LIST_ITEM_HEIGHT = 37;
const VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER = 8;
const VIRTUAL_LIST_PADDING = 8;
// Some list items have icons or checkboxes so we need some extra width
const VIRTUAL_LIST_WIDTH_EXTRA = 36;
// A virtualized version of the SelectMenu, descriptions for SelectableValue options not supported since those are of a variable height.
//
// To support the virtualized list we have to "guess" the width of the menu container based on the longest available option.
// the reason for this is because all of the options will be positioned absolute, this takes them out of the document and no space
// is created for them, thus the container can't grow to accomodate.
//
// VIRTUAL_LIST_ITEM_HEIGHT and WIDTH_ESTIMATE_MULTIPLIER are both magic numbers.
// Some characters (such as emojis and other unicode characters) may consist of multiple code points in which case the width would be inaccurate (but larger than needed).
export const VirtualizedSelectMenu = ({ children, maxHeight, options, getValue }: MenuListProps<SelectableValue>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const [value] = getValue();
const valueIndex = value ? options.findIndex((option: SelectableValue<unknown>) => option.value === value.value) : 0;
const valueYOffset = valueIndex * VIRTUAL_LIST_ITEM_HEIGHT;
if (!Array.isArray(children)) {
return null;
}
const longestOption = max(options.map((option) => option.label?.length)) ?? 0;
const widthEstimate =
longestOption * VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER + VIRTUAL_LIST_PADDING * 2 + VIRTUAL_LIST_WIDTH_EXTRA;
const heightEstimate = Math.min(options.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight);
// Try to scroll to keep current value in the middle
const scrollOffset = Math.max(0, valueYOffset - heightEstimate / 2);
return (
<List
className={styles.menu}
height={heightEstimate}
width={widthEstimate}
aria-label="Select options menu"
itemCount={children.length}
itemSize={VIRTUAL_LIST_ITEM_HEIGHT}
initialScrollOffset={scrollOffset}
>
{({ index, style }) => <div style={{ ...style, overflow: 'hidden' }}>{children[index]}</div>}
</List>
);
};
VirtualizedSelectMenu.displayName = 'VirtualizedSelectMenu';
interface SelectMenuOptionProps<T> {
isDisabled: boolean;
isFocused: boolean;
isSelected: boolean;
innerProps: JSX.IntrinsicElements['div'];
innerRef: RefCallback<HTMLDivElement>;
renderOptionLabel?: (value: SelectableValue<T>) => JSX.Element;
data: SelectableValue<T>;
}
export const SelectMenuOptions = ({
children,
data,
innerProps,
innerRef,
isFocused,
isSelected,
renderOptionLabel,
}: React.PropsWithChildren<SelectMenuOptionProps<unknown>>) => {
const theme = useTheme2();
const styles = getSelectStyles(theme);
const icon = data.icon ? toIconName(data.icon) : undefined;
// We are removing onMouseMove and onMouseOver from innerProps because they cause the whole
// list to re-render everytime the user hovers over an option. This is a performance issue.
// See https://github.com/JedWatson/react-select/issues/3128#issuecomment-451936743
const { onMouseMove, onMouseOver, ...rest } = innerProps;
return (
<div
ref={innerRef}
className={cx(
styles.option,
isFocused && styles.optionFocused,
isSelected && styles.optionSelected,
data.isDisabled && styles.optionDisabled
)}
{...rest}
aria-label="Select option"
title={data.title}
>
{icon && <Icon name={icon} className={styles.optionIcon} />}
{data.imgUrl && <img className={styles.optionImage} src={data.imgUrl} alt={data.label || String(data.value)} />}
<div className={styles.optionBody}>
<span>{renderOptionLabel ? renderOptionLabel(data) : children}</span>
{data.description && <div className={styles.optionDescription}>{data.description}</div>}
{data.component && <data.component />}
</div>
</div>
);
};
SelectMenuOptions.displayName = 'SelectMenuOptions';