Skip to content

Commit 50742fe

Browse files
authored
feat(menu-list-item): Details overlay (#68606)
Allow showing item details inside an overlay (`showDetailsInOverlay`) in case we want to show more detailed information than just one line of text. - closes #68514
1 parent 1c7d2b4 commit 50742fe

File tree

3 files changed

+104
-6
lines changed

3 files changed

+104
-6
lines changed

static/app/components/comboBox/comboBox.stories.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,41 @@
11
import {Fragment, useState} from 'react';
2+
import styled from '@emotion/styled';
23

34
import type {ComboBoxOptionOrSection} from 'sentry/components/comboBox/types';
5+
import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable';
46
import Matrix from 'sentry/components/stories/matrix';
57
import SizingWindow from 'sentry/components/stories/sizingWindow';
68
import storyBook from 'sentry/stories/storyBook';
9+
import {space} from 'sentry/styles/space';
710

811
import {ComboBox} from './';
912

13+
const Divider = styled('hr')`
14+
margin: ${space(1)} 0;
15+
border: none;
16+
border-top: 1px solid ${p => p.theme.innerBorder};
17+
`;
18+
1019
const options: ComboBoxOptionOrSection<string>[] = [
1120
{label: 'Option One', value: 'opt_one'},
12-
{label: 'Option Two', value: 'opt_two'},
13-
{label: 'Option Three', value: 'opt_three'},
21+
{label: 'Option Two', value: 'opt_two', details: 'This is a description'},
22+
{
23+
label: 'Option Three',
24+
value: 'opt_three',
25+
details: (
26+
<Fragment>
27+
<strong>{'Option Three (deprecated)'}</strong>
28+
<Divider />
29+
This is a description using JSX.
30+
<Divider />
31+
<KeyValueTable>
32+
<KeyValueTableRow keyName="Coffee" value="Black hot drink" />
33+
<KeyValueTableRow keyName="Milk" value="White cold drink" />
34+
</KeyValueTable>
35+
</Fragment>
36+
),
37+
showDetailsInOverlay: true,
38+
},
1439
{label: 'Disabled', value: 'opt_dis', disabled: true},
1540
{label: 'Option Four', textValue: 'included in search', value: 'opt_four'},
1641
{

static/app/components/compactSelect/listBox/option.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export function ListBoxOption({item, listState, size}: ListBoxOptionProps) {
3333
tooltip,
3434
tooltipOptions,
3535
selectionMode,
36+
showDetailsInOverlay,
3637
} = item.props;
3738
const multiple = selectionMode
3839
? selectionMode === 'multiple'
@@ -97,6 +98,7 @@ export function ListBoxOption({item, listState, size}: ListBoxOptionProps) {
9798
labelProps={labelPropsMemo}
9899
leadingItems={leadingItemsMemo}
99100
trailingItems={trailingItems}
101+
showDetailsInOverlay={showDetailsInOverlay}
100102
tooltip={tooltip}
101103
tooltipOptions={tooltipOptions}
102104
data-test-id={item.key}

static/app/components/menuListItem.tsx

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import {forwardRef as reactForwardRef, memo, useMemo} from 'react';
1+
import {forwardRef as reactForwardRef, memo, useMemo, useRef, useState} from 'react';
2+
import {createPortal} from 'react-dom';
3+
import {usePopper} from 'react-popper';
24
import isPropValid from '@emotion/is-prop-valid';
3-
import type {Theme} from '@emotion/react';
5+
import {type Theme, useTheme} from '@emotion/react';
46
import styled from '@emotion/styled';
57

68
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
9+
import {Overlay, PositionWrapper} from 'sentry/components/overlay';
710
import type {TooltipProps} from 'sentry/components/tooltip';
811
import {Tooltip} from 'sentry/components/tooltip';
912
import {space} from 'sentry/styles/space';
1013
import domId from 'sentry/utils/domId';
14+
import mergeRefs from 'sentry/utils/mergeRefs';
1115
import type {FormSize} from 'sentry/utils/theme';
1216

1317
/**
@@ -56,6 +60,10 @@ export type MenuListItemProps = {
5660
* Accented text and background (on hover) colors.
5761
*/
5862
priority?: Priority;
63+
/**
64+
* Whether to show the details in an overlay when the item is hovered / focused.
65+
*/
66+
showDetailsInOverlay?: boolean;
5967
/**
6068
* Determines the item's font sizes and internal paddings.
6169
*/
@@ -115,11 +123,13 @@ function BaseMenuListItem({
115123
innerWrapProps = {},
116124
labelProps = {},
117125
detailsProps = {},
126+
showDetailsInOverlay = false,
118127
tooltip,
119128
tooltipOptions = {delay: 500},
120129
forwardRef,
121130
...props
122131
}: Props) {
132+
const itemRef = useRef<HTMLLIElement>(null);
123133
const labelId = useMemo(() => domId('menuitem-label-'), []);
124134
const detailId = useMemo(() => domId('menuitem-details-'), []);
125135

@@ -130,7 +140,7 @@ function BaseMenuListItem({
130140
aria-labelledby={labelId}
131141
aria-describedby={detailId}
132142
as={as}
133-
ref={forwardRef}
143+
ref={mergeRefs([forwardRef, itemRef])}
134144
{...props}
135145
>
136146
<Tooltip skipWrapper title={tooltip} {...tooltipOptions}>
@@ -167,7 +177,7 @@ function BaseMenuListItem({
167177
>
168178
{label}
169179
</Label>
170-
{details && (
180+
{!showDetailsInOverlay && details && (
171181
<Details
172182
id={detailId}
173183
disabled={disabled}
@@ -177,6 +187,11 @@ function BaseMenuListItem({
177187
{details}
178188
</Details>
179189
)}
190+
{showDetailsInOverlay && details && isFocused && (
191+
<DetailsOverlay size={size} id={detailId} itemRef={itemRef}>
192+
{details}
193+
</DetailsOverlay>
194+
)}
180195
</LabelWrap>
181196
{trailingItems && (
182197
<TrailingItems
@@ -203,6 +218,62 @@ const MenuListItem = memo(
203218

204219
export default MenuListItem;
205220

221+
function DetailsOverlay({
222+
children,
223+
size,
224+
id,
225+
itemRef,
226+
}: {
227+
children: React.ReactNode;
228+
id: string;
229+
itemRef: React.RefObject<HTMLLIElement>;
230+
size: Props['size'];
231+
}) {
232+
const theme = useTheme();
233+
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
234+
const popper = usePopper(itemRef.current, overlayElement, {
235+
placement: 'right-start',
236+
modifiers: [
237+
{
238+
name: 'offset',
239+
options: {
240+
offset: [0, 8],
241+
},
242+
},
243+
],
244+
});
245+
246+
return createPortal(
247+
<StyledPositionWrapper
248+
{...popper.attributes.popper}
249+
ref={setOverlayElement}
250+
zIndex={theme.zIndex.tooltip}
251+
style={popper.styles.popper}
252+
>
253+
<StyledOverlay id={id} placement="right-start" size={size}>
254+
{children}
255+
</StyledOverlay>
256+
</StyledPositionWrapper>,
257+
document.body
258+
);
259+
}
260+
261+
const StyledPositionWrapper = styled(PositionWrapper)`
262+
&[data-popper-reference-hidden='true'] {
263+
opacity: 0;
264+
pointer-events: none;
265+
}
266+
`;
267+
268+
const StyledOverlay = styled(Overlay)<{
269+
size: Props['size'];
270+
}>`
271+
padding: ${p => getVerticalPadding(p.size)};
272+
font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};
273+
cursor: auto;
274+
user-select: text;
275+
`;
276+
206277
const MenuItemWrap = styled('li')`
207278
position: static;
208279
list-style-type: none;

0 commit comments

Comments
 (0)