Skip to content

Commit 90ec16e

Browse files
authored
chore(compass-components): refactor ItemActionControls (#6552)
* Refactor "expandedPresentation" into "expandedAs" * Moved item-action-controls into a sub-directory * Split to multiple files * Update action-glyph inline docs
1 parent 074292b commit 90ec16e

File tree

16 files changed

+768
-636
lines changed

16 files changed

+768
-636
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
3+
import { Icon } from '../leafygreen';
4+
import type { ItemActionButtonSize } from './constants';
5+
6+
// As we are using this component to render icon in MenuItem,
7+
// and it does cloneElement on glyph, here we are accepting all the
8+
// props that are passed during clone process.
9+
type IconProps = React.ComponentProps<typeof Icon>;
10+
type ActionGlyphProps = Omit<IconProps, 'size' | 'glyph'> & {
11+
glyph?: React.ReactChild;
12+
size?: ItemActionButtonSize;
13+
};
14+
15+
export const ActionGlyph = ({ glyph, size, ...props }: ActionGlyphProps) => {
16+
if (typeof glyph === 'string') {
17+
return <Icon size={size} glyph={glyph} {...props} />;
18+
}
19+
20+
if (React.isValidElement(glyph)) {
21+
return glyph;
22+
}
23+
24+
return null;
25+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const ItemActionButtonSize = {
2+
XSmall: 'xsmall',
3+
Small: 'small',
4+
Default: 'default',
5+
} as const;
6+
7+
export type ItemActionButtonSize =
8+
typeof ItemActionButtonSize[keyof typeof ItemActionButtonSize];
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import React, { useCallback, useRef, useState } from 'react';
2+
import { css } from '@leafygreen-ui/emotion';
3+
import type { ButtonProps } from '@leafygreen-ui/button';
4+
5+
import { Button, Icon, Menu, MenuItem, MenuSeparator } from '../leafygreen';
6+
import { WorkspaceContainer } from '../workspace-container';
7+
8+
import { ItemActionButtonSize } from './constants';
9+
import { actionTestId } from './utils';
10+
import { ActionGlyph } from './action-glyph';
11+
import { isSeparatorMenuAction, type MenuAction } from './item-action-menu';
12+
13+
const hiddenOnNarrowStyles = css({
14+
[`@container ${WorkspaceContainer.toolbarContainerQueryName} (width < 900px)`]:
15+
{
16+
display: 'none',
17+
},
18+
});
19+
20+
export type DropdownMenuButtonProps<Action extends string> = {
21+
actions: MenuAction<Action>[];
22+
onAction(actionName: Action): void;
23+
usePortal?: boolean;
24+
iconSize?: ItemActionButtonSize;
25+
isVisible?: boolean;
26+
activeAction?: Action;
27+
'data-testid'?: string;
28+
buttonText: string;
29+
buttonProps: ButtonProps;
30+
hideOnNarrow?: boolean;
31+
};
32+
33+
export function DropdownMenuButton<Action extends string>({
34+
isVisible = true,
35+
actions,
36+
onAction,
37+
usePortal,
38+
activeAction,
39+
buttonText,
40+
buttonProps,
41+
iconSize = ItemActionButtonSize.Default,
42+
'data-testid': dataTestId,
43+
hideOnNarrow = true,
44+
}: DropdownMenuButtonProps<Action>) {
45+
// this ref is used by the Menu component to calculate the height and position
46+
// of the menu, and by us to give back the focus to the trigger when the menu
47+
// is closed (https://jira.mongodb.org/browse/PD-1674).
48+
const menuTriggerRef = useRef<HTMLButtonElement | null>(null);
49+
const [isMenuOpen, setIsMenuOpen] = useState(false);
50+
51+
const onClick = useCallback(
52+
(evt) => {
53+
evt.stopPropagation();
54+
if (evt.currentTarget.dataset.menuitem) {
55+
setIsMenuOpen(false);
56+
// Workaround for https://jira.mongodb.org/browse/PD-1674
57+
menuTriggerRef.current?.focus();
58+
}
59+
onAction(evt.currentTarget.dataset.action);
60+
},
61+
[onAction]
62+
);
63+
64+
const shouldRender = isMenuOpen || (isVisible && actions.length > 0);
65+
66+
if (!shouldRender) {
67+
return null;
68+
}
69+
70+
return (
71+
<Menu
72+
open={isMenuOpen}
73+
setOpen={setIsMenuOpen}
74+
justify="start"
75+
refEl={menuTriggerRef}
76+
usePortal={usePortal}
77+
data-testid={dataTestId}
78+
trigger={({
79+
onClick,
80+
children,
81+
}: {
82+
onClick: React.MouseEventHandler<HTMLButtonElement>;
83+
children: React.ReactNode;
84+
}) => {
85+
return (
86+
<Button
87+
{...buttonProps}
88+
ref={menuTriggerRef}
89+
data-testid={dataTestId ? `${dataTestId}-show-actions` : undefined}
90+
onClick={(evt) => {
91+
evt.stopPropagation();
92+
onClick && onClick(evt);
93+
}}
94+
rightGlyph={<Icon glyph={'CaretDown'} />}
95+
title={buttonText}
96+
>
97+
<span className={hideOnNarrow ? hiddenOnNarrowStyles : undefined}>
98+
{buttonText}
99+
</span>
100+
{children}
101+
</Button>
102+
);
103+
}}
104+
>
105+
{actions.map((menuAction, idx) => {
106+
if (isSeparatorMenuAction(menuAction)) {
107+
return <MenuSeparator key={`separator-${idx}`} />;
108+
}
109+
110+
const { action, label, icon } = menuAction;
111+
return (
112+
<MenuItem
113+
active={activeAction === action}
114+
key={action}
115+
data-testid={actionTestId<Action>(dataTestId, action)}
116+
data-action={action}
117+
data-menuitem={true}
118+
glyph={<ActionGlyph glyph={icon} size={iconSize} />}
119+
onClick={onClick}
120+
>
121+
{label}
122+
</MenuItem>
123+
);
124+
})}
125+
</Menu>
126+
);
127+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
import { css, cx } from '@leafygreen-ui/emotion';
3+
import { spacing } from '@leafygreen-ui/tokens';
4+
5+
import { ItemActionButtonSize } from './constants';
6+
import type { ItemComponentProps } from './types';
7+
import { SmallIconButton } from './small-icon-button';
8+
9+
// TODO: Move to a parent component - or a flex gap
10+
const buttonStyle = css({
11+
'&:not(:first-child)': {
12+
marginLeft: spacing[100],
13+
},
14+
});
15+
16+
export function ItemActionButton<Action extends string>({
17+
action,
18+
icon = <></>,
19+
label,
20+
tooltip,
21+
iconSize = ItemActionButtonSize.Default,
22+
onClick,
23+
iconClassName,
24+
className,
25+
iconStyle,
26+
isDisabled,
27+
'data-testid': dataTestId,
28+
}: ItemComponentProps<Action>) {
29+
return (
30+
<SmallIconButton
31+
key={action}
32+
glyph={icon}
33+
label={label}
34+
title={!tooltip ? label : undefined}
35+
size={iconSize}
36+
data-action={action}
37+
data-testid={dataTestId}
38+
onClick={onClick}
39+
className={cx(buttonStyle, iconClassName, className)}
40+
style={iconStyle}
41+
disabled={isDisabled}
42+
/>
43+
);
44+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import React, { useMemo } from 'react';
2+
import { spacing } from '@leafygreen-ui/tokens';
3+
import { css, cx } from '@leafygreen-ui/emotion';
4+
5+
import { ItemActionMenu } from './item-action-menu';
6+
import { ItemActionButtonSize } from './constants';
7+
import type { ItemAction, ItemSeparator } from './types';
8+
import { ItemActionGroup } from './item-action-group';
9+
10+
const actionControlsStyle = css({
11+
flex: 'none',
12+
marginLeft: 'auto',
13+
alignItems: 'center',
14+
display: 'flex',
15+
});
16+
17+
// Action buttons are rendered 4px apart from each other. With this we keep the
18+
// same spacing also when action buttons are rendered alongside action menu
19+
// (happens when collapseAfter prop is specified)
20+
const actionMenuWithActionControlsStyles = css({
21+
marginLeft: spacing[100],
22+
});
23+
24+
export type ItemActionControlsProps<Action extends string> = {
25+
isVisible?: boolean;
26+
actions: (ItemAction<Action> | ItemSeparator)[];
27+
onAction(actionName: Action): void;
28+
className?: string;
29+
menuClassName?: string;
30+
iconSize?: ItemActionButtonSize;
31+
iconClassName?: string;
32+
iconStyle?: React.CSSProperties;
33+
// The number of actions to show before collapsing other actions into a menu
34+
collapseAfter?: number;
35+
// When using `collapseAfter`, this option is not used.
36+
collapseToMenuThreshold?: number;
37+
usePortal?: boolean;
38+
'data-testid'?: string;
39+
};
40+
41+
export function ItemActionControls<Action extends string>({
42+
isVisible = true,
43+
actions,
44+
onAction,
45+
className,
46+
menuClassName,
47+
iconClassName,
48+
iconStyle,
49+
iconSize = ItemActionButtonSize.Default,
50+
usePortal,
51+
collapseAfter = 0,
52+
collapseToMenuThreshold = 2,
53+
'data-testid': dataTestId,
54+
}: ItemActionControlsProps<Action>) {
55+
const sharedProps = useMemo(
56+
() => ({
57+
isVisible,
58+
onAction,
59+
className: cx('item-action-controls', className),
60+
iconClassName,
61+
iconStyle,
62+
iconSize,
63+
'data-testid': dataTestId,
64+
}),
65+
[
66+
isVisible,
67+
onAction,
68+
className,
69+
iconClassName,
70+
iconStyle,
71+
iconSize,
72+
dataTestId,
73+
]
74+
);
75+
const sharedMenuProps = useMemo(
76+
() => ({
77+
menuClassName,
78+
usePortal,
79+
}),
80+
[menuClassName, usePortal]
81+
);
82+
if (actions.length === 0) {
83+
return null;
84+
}
85+
86+
// When user wants to show a few actions and collapse the rest into a menu
87+
if (collapseAfter > 0) {
88+
const visibleActions = actions.slice(0, collapseAfter);
89+
const collapsedActions = actions.slice(collapseAfter);
90+
return (
91+
<div className={actionControlsStyle}>
92+
<ItemActionGroup
93+
actions={visibleActions}
94+
{...sharedProps}
95+
></ItemActionGroup>
96+
<ItemActionMenu
97+
actions={collapsedActions}
98+
{...sharedProps}
99+
{...sharedMenuProps}
100+
className={cx(
101+
actionMenuWithActionControlsStyles,
102+
sharedProps.className
103+
)}
104+
></ItemActionMenu>
105+
</div>
106+
);
107+
}
108+
109+
const shouldShowMenu = actions.length >= collapseToMenuThreshold;
110+
111+
if (shouldShowMenu) {
112+
return (
113+
<ItemActionMenu
114+
actions={actions}
115+
{...sharedProps}
116+
{...sharedMenuProps}
117+
></ItemActionMenu>
118+
);
119+
}
120+
121+
return <ItemActionGroup actions={actions} {...sharedProps}></ItemActionGroup>;
122+
}

0 commit comments

Comments
 (0)