import {forwardRef as reactForwardRef, useMemo} from 'react'; import isPropValid from '@emotion/is-prop-valid'; import styled from '@emotion/styled'; import Tooltip, {InternalTooltipProps} from 'sentry/components/tooltip'; import space from 'sentry/styles/space'; import {defined} from 'sentry/utils'; import domId from 'sentry/utils/domId'; import {FormSize, Theme} from 'sentry/utils/theme'; /** * Menu item priority. Determines the text and background color. */ type Priority = 'primary' | 'danger' | 'default'; export type MenuListItemProps = { /** * Optional descriptive text. Like 'label', should preferably be a string or * have appropriate aria-labels. */ details?: React.ReactNode; /** * Whether the item is disabled (if true, the item will be grayed out and * non-interactive). */ disabled?: boolean; /** * Item label. Should preferably be a string. If not, make sure that * there are appropriate aria-labels. */ label?: React.ReactNode; /* * Items to be added to the left of the label */ leadingItems?: React.ReactNode; /* * Whether leading items should be centered with respect to the entire * height of the item. If false (default), they will be centered with * respect to the first line of the label element. */ leadingItemsSpanFullHeight?: boolean; /** * Accented text and background (on hover) colors. */ priority?: Priority; /** * Whether to show a line divider below this item */ showDivider?: boolean; /** * Determines the item's font sizes and internal paddings. */ size?: FormSize; /** * Optional tooltip that appears when the use hovers over the item. This is * not very visible - if possible, add additional text via the `details` * prop instead. */ tooltip?: React.ReactNode; /** * Additional props to be passed into . */ tooltipOptions?: Omit; /* * Items to be added to the right of the label. */ trailingItems?: React.ReactNode; /* * Whether trailing items should be centered wrt/ the entire height of the * item. If false (default), they will be centered wrt/ the first line of * the label element. */ trailingItemsSpanFullHeight?: boolean; }; interface OtherProps { as?: React.ElementType; detailsProps?: object; innerWrapProps?: object; isFocused?: boolean; labelProps?: object; } interface Props extends MenuListItemProps, OtherProps { forwardRef: React.ForwardedRef; } function BaseMenuListItem({ label, details, as = 'li', priority = 'default', size, disabled = false, showDivider = false, leadingItems = false, leadingItemsSpanFullHeight = false, trailingItems = false, trailingItemsSpanFullHeight = false, isFocused = false, innerWrapProps = {}, labelProps = {}, detailsProps = {}, tooltip, tooltipOptions = {delay: 500}, forwardRef, ...props }: Props) { const labelId = useMemo(() => domId('menuitem-label-'), []); const detailId = useMemo(() => domId('menuitem-details-'), []); return ( {leadingItems && ( {leadingItems} )} {details && (
{details}
)}
{trailingItems && ( {trailingItems} )}
); } const MenuListItem = reactForwardRef( (props, ref) => ); export default MenuListItem; const MenuItemWrap = styled('li')` position: static; list-style-type: none; margin: 0; padding: 0 ${space(0.5)}; cursor: pointer; &:focus { outline: none; } &:focus-visible { outline: none; } `; function getTextColor({ theme, priority, disabled, }: { disabled: boolean; priority: Priority; theme: Theme; }) { if (disabled) { return theme.subText; } switch (priority) { case 'primary': return theme.activeText; case 'danger': return theme.errorText; case 'default': default: return theme.textColor; } } function getFocusBackground({theme, priority}: {priority: Priority; theme: Theme}) { switch (priority) { case 'primary': return theme.purple100; case 'danger': return theme.red100; case 'default': default: return theme.hover; } } export const InnerWrap = styled('div', { shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop) && !['disabled', 'isFocused', 'priority'].includes(prop), })<{ disabled: boolean; isFocused: boolean; priority: Priority; size: Props['size']; }>` display: flex; position: relative; padding: 0 ${space(1)} 0 ${space(1.5)}; border-radius: ${p => p.theme.borderRadius}; box-sizing: border-box; font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize}; &, &:hover { color: ${getTextColor}; } ${p => p.disabled && `cursor: default;`} ${p => p.isFocused && ` z-index: 1; ::before, ::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; } /* Background to hide the previous item's divider */ ::before { background: ${p.theme.background}; z-index: -1; } /* Hover/focus background */ ::after { background: ${getFocusBackground(p)}; border-radius: inherit; z-index: -1; } `} `; /** * Returns the appropriate vertical padding based on the size prop. To be used * as top/bottom padding/margin in ContentWrap and LeadingItems. */ const getVerticalPadding = (size: Props['size']) => { switch (size) { case 'xs': return space(0.5); case 'sm': return space(0.75); case 'md': default: return space(1); } }; const ContentWrap = styled('div')<{ isFocused: boolean; showDivider: boolean; size: Props['size']; }>` position: relative; width: 100%; min-width: 0; display: flex; gap: ${space(1)}; justify-content: space-between; padding: ${p => getVerticalPadding(p.size)} 0; ${p => p.showDivider && !p.isFocused && ` ${MenuItemWrap}:not(:last-child) &::after { content: ''; position: absolute; left: 0; bottom: 0; width: 100%; height: 1px; box-shadow: 0 1px 0 0 ${p.theme.innerBorder}; } `} `; const LeadingItems = styled('div')<{ disabled: boolean; size: Props['size']; spanFullHeight: boolean; }>` display: flex; align-items: center; height: 1.4em; gap: ${space(1)}; margin-top: ${p => getVerticalPadding(p.size)}; margin-right: ${space(1)}; ${p => p.disabled && `opacity: 0.5;`} ${p => p.spanFullHeight && `height: 100%;`} `; const LabelWrap = styled('div')` padding-right: ${space(1)}; width: 100%; min-width: 0; `; const Label = styled('p')` margin-bottom: 0; line-height: 1.4; white-space: nowrap; ${p => p.theme.overflowEllipsis} `; const Details = styled('p')<{disabled: boolean; priority: Priority}>` font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.subText}; line-height: 1.2; margin-bottom: 0; ${p => p.theme.overflowEllipsis} ${p => p.priority !== 'default' && `color: ${getTextColor(p)};`} `; const TrailingItems = styled('div')<{disabled: boolean; spanFullHeight: boolean}>` display: flex; align-items: center; height: 1.4em; gap: ${space(1)}; margin-right: ${space(0.5)}; ${p => p.disabled && `opacity: 0.5;`} ${p => p.spanFullHeight && `height: 100%;`} `;