123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- 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 <Tooltip />.
- */
- tooltipOptions?: Omit<InternalTooltipProps, 'children' | 'title' | 'className'>;
- /*
- * 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<HTMLLIElement>;
- }
- 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 (
- <MenuItemWrap
- role="menuitem"
- aria-disabled={disabled}
- aria-labelledby={labelId}
- aria-describedby={detailId}
- as={as}
- ref={forwardRef}
- {...props}
- >
- <Tooltip skipWrapper title={tooltip} {...tooltipOptions}>
- <InnerWrap
- isFocused={isFocused}
- disabled={disabled}
- priority={priority}
- size={size}
- {...innerWrapProps}
- >
- {leadingItems && (
- <LeadingItems
- disabled={disabled}
- spanFullHeight={leadingItemsSpanFullHeight}
- size={size}
- >
- {leadingItems}
- </LeadingItems>
- )}
- <ContentWrap
- isFocused={isFocused}
- showDivider={defined(details) || showDivider}
- size={size}
- >
- <LabelWrap>
- <Label id={labelId} aria-hidden="true" {...labelProps}>
- {label}
- </Label>
- {details && (
- <Details
- id={detailId}
- disabled={disabled}
- priority={priority}
- {...detailsProps}
- >
- {details}
- </Details>
- )}
- </LabelWrap>
- {trailingItems && (
- <TrailingItems
- disabled={disabled}
- spanFullHeight={trailingItemsSpanFullHeight}
- >
- {trailingItems}
- </TrailingItems>
- )}
- </ContentWrap>
- </InnerWrap>
- </Tooltip>
- </MenuItemWrap>
- );
- }
- const MenuListItem = reactForwardRef<HTMLLIElement, MenuListItemProps & OtherProps>(
- (props, ref) => <BaseMenuListItem {...props} forwardRef={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%;`}
- `;
|