mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
388 lines
9.2 KiB
TypeScript
388 lines
9.2 KiB
TypeScript
import {forwardRef as reactForwardRef, memo, useMemo} from 'react';
|
|
import isPropValid from '@emotion/is-prop-valid';
|
|
import type {Theme} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
|
|
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
|
|
import type {TooltipProps} from 'sentry/components/tooltip';
|
|
import {Tooltip} from 'sentry/components/tooltip';
|
|
import {space} from 'sentry/styles/space';
|
|
import domId from 'sentry/utils/domId';
|
|
import type {FormSize} from 'sentry/utils/theme';
|
|
|
|
/**
|
|
* Menu item priority. Determines the text and background color.
|
|
*/
|
|
type Priority = 'primary' | 'danger' | 'default';
|
|
|
|
/**
|
|
* Leading/trailing items to be rendered alongside the main text label.
|
|
*/
|
|
type EdgeItems =
|
|
| React.ReactNode
|
|
| ((state: {
|
|
disabled: boolean;
|
|
isFocused: boolean;
|
|
isSelected: boolean;
|
|
}) => React.ReactNode);
|
|
|
|
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?: EdgeItems;
|
|
/**
|
|
* 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;
|
|
/**
|
|
* 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<TooltipProps, 'children' | 'title' | 'className'>;
|
|
/**
|
|
* Items to be added to the right of the label.
|
|
*/
|
|
trailingItems?: EdgeItems;
|
|
/**
|
|
* 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;
|
|
isPressed?: boolean;
|
|
isSelected?: boolean;
|
|
labelProps?: object;
|
|
showDivider?: boolean;
|
|
}
|
|
|
|
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,
|
|
isSelected = false,
|
|
isPressed,
|
|
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}
|
|
>
|
|
<StyledInteractionStateLayer
|
|
isHovered={isFocused}
|
|
isPressed={isPressed}
|
|
higherOpacity={priority !== 'default'}
|
|
/>
|
|
{leadingItems && (
|
|
<LeadingItems
|
|
disabled={disabled}
|
|
spanFullHeight={leadingItemsSpanFullHeight}
|
|
size={size}
|
|
>
|
|
{typeof leadingItems === 'function'
|
|
? leadingItems({disabled, isFocused, isSelected})
|
|
: leadingItems}
|
|
</LeadingItems>
|
|
)}
|
|
<ContentWrap isFocused={isFocused} showDivider={showDivider} size={size}>
|
|
<LabelWrap>
|
|
<Label
|
|
id={labelId}
|
|
data-test-id="menu-list-item-label"
|
|
aria-hidden="true"
|
|
{...labelProps}
|
|
>
|
|
{label}
|
|
</Label>
|
|
{details && (
|
|
<Details
|
|
id={detailId}
|
|
disabled={disabled}
|
|
priority={priority}
|
|
{...detailsProps}
|
|
>
|
|
{details}
|
|
</Details>
|
|
)}
|
|
</LabelWrap>
|
|
{trailingItems && (
|
|
<TrailingItems
|
|
disabled={disabled}
|
|
spanFullHeight={trailingItemsSpanFullHeight}
|
|
>
|
|
{typeof trailingItems === 'function'
|
|
? trailingItems({disabled, isFocused, isSelected})
|
|
: trailingItems}
|
|
</TrailingItems>
|
|
)}
|
|
</ContentWrap>
|
|
</InnerWrap>
|
|
</Tooltip>
|
|
</MenuItemWrap>
|
|
);
|
|
}
|
|
|
|
const MenuListItem = memo(
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;`}
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: -1;
|
|
}
|
|
|
|
${p =>
|
|
p.isFocused &&
|
|
`
|
|
z-index: 1;
|
|
/* Background to hide the previous item's divider */
|
|
::before {
|
|
background: ${p.theme.backgroundElevated};
|
|
}
|
|
`}
|
|
`;
|
|
|
|
const StyledInteractionStateLayer = styled(InteractionStateLayer)`
|
|
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 &&
|
|
`
|
|
li: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('div')`
|
|
margin-bottom: 0;
|
|
line-height: 1.4;
|
|
white-space: nowrap;
|
|
|
|
${p => p.theme.overflowEllipsis}
|
|
`;
|
|
|
|
const Details = styled('div')<{disabled: boolean; priority: Priority}>`
|
|
font-size: ${p => p.theme.fontSizeSmall};
|
|
color: ${p => p.theme.subText};
|
|
line-height: 1.2;
|
|
margin-bottom: 0;
|
|
|
|
${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%;`}
|
|
`;
|