Skip to content

Commit 160ef61

Browse files
Refactor App Menu Components and Introduce Split Button with Notification
- Updated `tsconfig.json` to include ambient UI types. - Refactored imports in `types.ts` and `app_menu_action_button.tsx` to use a local `SplitButtonWithNotification` component instead of the removed `@kbn/split-button`. - Added a new `split_button_with_notification.tsx` component to handle split button functionality with notification indicators. - Adjusted `app_menu_popover_action_buttons.tsx` to utilize the new split button component and added a `fullWidth` prop for layout flexibility. - Removed the old `SplitButtonWithNotification` implementation from the private `kbn-split-button` package, including its tests and stories. This refactor enhances the modularity and maintainability of the app menu components.
1 parent db5a1ab commit 160ef61

10 files changed

Lines changed: 292 additions & 206 deletions

File tree

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_action_button.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
*/
99

1010
import React, { useRef, type MouseEvent } from 'react';
11-
import { SplitButtonWithNotification } from '@kbn/split-button';
1211
import { upperFirst } from 'lodash';
1312
import { EuiButton, EuiHideFor, EuiToolTip, useEuiTheme } from '@elastic/eui';
1413
import { css } from '@emotion/react';
@@ -19,13 +18,15 @@ import {
1918
} from '../constants';
2019
import { createReturnFocus, getIsSelectedColor, getTooltip, isDisabled } from '../utils';
2120
import { AppMenuPopover } from './app_menu_popover';
21+
import { SplitButtonWithNotification } from './split_button_with_notification';
2222
import type { AppMenuPrimaryActionItem, AppMenuSplitButtonProps } from '../types';
2323

2424
type AppMenuActionButtonProps = AppMenuPrimaryActionItem & {
2525
isPopoverOpen: boolean;
2626
onPopoverToggle: () => void;
2727
onPopoverClose: () => void;
2828
onCloseOverflowButton?: () => void;
29+
fullWidth?: boolean;
2930
};
3031

3132
export const AppMenuActionButton = (props: AppMenuActionButtonProps) => {
@@ -51,6 +52,7 @@ export const AppMenuActionButton = (props: AppMenuActionButtonProps) => {
5152
onPopoverToggle,
5253
onPopoverClose,
5354
onCloseOverflowButton,
55+
fullWidth,
5456
} = props;
5557

5658
const itemText = upperFirst(label);
@@ -109,6 +111,7 @@ export const AppMenuActionButton = (props: AppMenuActionButtonProps) => {
109111
size: 's' as const,
110112
iconSize: 'm' as const,
111113
'aria-haspopup': (hasSplitItems ? 'menu' : undefined) as 'menu' | undefined,
114+
fullWidth,
112115
};
113116

114117
// Target the split part of the button for popover behavior.

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/components/app_menu_popover_action_buttons.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,12 @@ export const AppMenuPopoverActionButtons = ({
4747
direction="column"
4848
justifyContent="center"
4949
gutterSize="m"
50-
alignItems="center"
50+
alignItems="stretch"
5151
css={containerCss}
5252
data-test-subj="app-menu-popover-action-buttons-container"
5353
>
5454
{primaryActionItem && (
55-
<EuiFlexItem grow={false}>
55+
<EuiFlexItem>
5656
<AppMenuActionButton
5757
{...primaryActionItem}
5858
run={(params) => {
@@ -65,6 +65,7 @@ export const AppMenuPopoverActionButtons = ({
6565
}}
6666
onPopoverClose={handleOnPopoverClose}
6767
onCloseOverflowButton={onCloseOverflowButton}
68+
fullWidth
6869
/>
6970
</EuiFlexItem>
7071
)}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React from 'react';
11+
import { render, screen } from '@testing-library/react';
12+
import userEvent from '@testing-library/user-event';
13+
import { SplitButtonWithNotification } from './split_button_with_notification';
14+
15+
const setup = (props: Partial<React.ComponentProps<typeof SplitButtonWithNotification>> = {}) => {
16+
const onMainButtonClick = jest.fn();
17+
const onSecondaryButtonClick = jest.fn();
18+
const user = userEvent.setup();
19+
20+
render(
21+
<SplitButtonWithNotification
22+
data-test-subj="split-button"
23+
onClick={onMainButtonClick}
24+
onSecondaryButtonClick={onSecondaryButtonClick}
25+
secondaryButtonIcon="chevronSingleDown"
26+
secondaryButtonAriaLabel="More options"
27+
{...props}
28+
>
29+
Save
30+
</SplitButtonWithNotification>
31+
);
32+
33+
return { onMainButtonClick, onSecondaryButtonClick, user };
34+
};
35+
36+
describe('<SplitButtonWithNotification />', () => {
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
});
40+
41+
it('should render the primary and secondary buttons', () => {
42+
setup();
43+
expect(screen.getByText('Save')).toBeInTheDocument();
44+
expect(screen.getByLabelText('More options')).toBeInTheDocument();
45+
});
46+
47+
it('should call onClick when the primary button is clicked', async () => {
48+
const { onMainButtonClick, user } = setup();
49+
await user.click(screen.getByText('Save'));
50+
expect(onMainButtonClick).toHaveBeenCalledTimes(1);
51+
expect(onMainButtonClick).toHaveBeenCalledWith(expect.objectContaining({ type: 'click' }));
52+
});
53+
54+
it('should call onSecondaryButtonClick when the secondary button is clicked', async () => {
55+
const { onSecondaryButtonClick, user } = setup();
56+
await user.click(screen.getByLabelText('More options'));
57+
expect(onSecondaryButtonClick).toHaveBeenCalledTimes(1);
58+
});
59+
60+
it('should not show the notification indicator by default', () => {
61+
setup();
62+
expect(screen.queryByTestId('split-button-notification-indicator')).not.toBeInTheDocument();
63+
});
64+
65+
it('should show the notification indicator when showNotificationIndicator is true', () => {
66+
setup({ showNotificationIndicator: true });
67+
expect(screen.getByTestId('split-button-notification-indicator')).toBeVisible();
68+
});
69+
70+
it('should not trigger onClick via the indicator when disabled', async () => {
71+
const onMainButtonClick = jest.fn();
72+
// bypass pointer-events: none on the outer wrapper — only the inner icon is interactive
73+
const user = userEvent.setup({ pointerEventsCheck: 0 });
74+
75+
render(
76+
<SplitButtonWithNotification
77+
onClick={onMainButtonClick}
78+
onSecondaryButtonClick={jest.fn()}
79+
secondaryButtonIcon="chevronSingleDown"
80+
secondaryButtonAriaLabel="More options"
81+
showNotificationIndicator={true}
82+
isDisabled={true}
83+
>
84+
Save
85+
</SplitButtonWithNotification>
86+
);
87+
88+
const indicator = screen.getByTestId('split-button-notification-indicator');
89+
await user.click(indicator);
90+
expect(onMainButtonClick).not.toHaveBeenCalled();
91+
});
92+
});
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React, { type MouseEventHandler } from 'react';
11+
import {
12+
EuiSplitButton,
13+
EuiIconTip,
14+
euiButtonSizeMap,
15+
useEuiTheme,
16+
type EuiButtonProps,
17+
type IconColor,
18+
type IconSize,
19+
type IconType,
20+
} from '@elastic/eui';
21+
import { css, type Interpolation, type Theme } from '@emotion/react';
22+
23+
export interface SplitButtonWithNotificationProps {
24+
children?: React.ReactNode;
25+
onClick?: React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
26+
iconType?: IconType;
27+
iconSize?: EuiButtonProps['iconSize'];
28+
isDisabled?: boolean;
29+
isLoading?: boolean;
30+
isMainButtonLoading?: boolean;
31+
isMainButtonDisabled?: boolean;
32+
isSelected?: boolean;
33+
color?: EuiButtonProps['color'];
34+
size?: EuiButtonProps['size'];
35+
href?: string;
36+
target?: string;
37+
id?: string;
38+
'data-test-subj'?: string;
39+
css?: Interpolation<Theme>;
40+
fullWidth?: boolean;
41+
'aria-haspopup'?: 'menu';
42+
43+
secondaryButtonIcon: IconType;
44+
secondaryButtonAriaLabel?: string;
45+
secondaryButtonTitle?: string;
46+
onSecondaryButtonClick?: React.MouseEventHandler<HTMLButtonElement>;
47+
isSecondaryButtonLoading?: boolean;
48+
isSecondaryButtonDisabled?: boolean;
49+
/** @deprecated Ignored — EuiSplitButton manages fill state uniformly. Kept for type compatibility. */
50+
secondaryButtonFill?: boolean;
51+
52+
showNotificationIndicator?: boolean;
53+
notificationIndicatorColor?: IconColor;
54+
notificationIndicatorSize?: IconSize;
55+
/** @note Intentional typo kept for backwards compatibility */
56+
notifcationIndicatorTooltipContent?: string;
57+
notificationIndicatorPosition?: {
58+
top?: number;
59+
right?: number;
60+
left?: number;
61+
bottom?: number;
62+
};
63+
notificationIndicatorHasStroke?: boolean;
64+
}
65+
66+
export const SplitButtonWithNotification = ({
67+
children,
68+
onClick,
69+
iconType,
70+
iconSize,
71+
isDisabled = false,
72+
isLoading = false,
73+
isMainButtonLoading,
74+
isMainButtonDisabled,
75+
isSelected,
76+
color = 'primary',
77+
size = 'm',
78+
href,
79+
target,
80+
id,
81+
'data-test-subj': dataTestSubj,
82+
css: cssProp,
83+
fullWidth,
84+
'aria-haspopup': ariaHasPopup,
85+
86+
secondaryButtonIcon,
87+
secondaryButtonAriaLabel,
88+
secondaryButtonTitle,
89+
onSecondaryButtonClick,
90+
isSecondaryButtonLoading,
91+
isSecondaryButtonDisabled,
92+
93+
showNotificationIndicator = false,
94+
notificationIndicatorColor = 'primary',
95+
notificationIndicatorSize = 'l',
96+
notifcationIndicatorTooltipContent,
97+
notificationIndicatorPosition,
98+
notificationIndicatorHasStroke = true,
99+
}: SplitButtonWithNotificationProps) => {
100+
const euiThemeContext = useEuiTheme();
101+
const { euiTheme } = euiThemeContext;
102+
103+
const buttonSizes = euiButtonSizeMap(euiThemeContext);
104+
const secondaryButtonWidth = buttonSizes[size]?.height;
105+
106+
const disableIndicatorOnClick = isDisabled || isLoading;
107+
108+
return (
109+
<div
110+
css={{
111+
position: 'relative' as const,
112+
display: fullWidth ? 'block' : 'inline-block',
113+
}}
114+
>
115+
<EuiSplitButton
116+
data-test-subj={dataTestSubj}
117+
color={color}
118+
size={size}
119+
isDisabled={isDisabled}
120+
isLoading={isLoading}
121+
css={[
122+
fullWidth &&
123+
css`
124+
display: flex;
125+
width: 100%;
126+
`,
127+
cssProp,
128+
]}
129+
>
130+
<EuiSplitButton.ActionPrimary
131+
id={id}
132+
onClick={onClick}
133+
iconType={iconType}
134+
iconSize={iconSize}
135+
isDisabled={isMainButtonDisabled}
136+
isLoading={isMainButtonLoading}
137+
isSelected={isSelected}
138+
href={href}
139+
target={target}
140+
fullWidth={fullWidth}
141+
aria-haspopup={ariaHasPopup}
142+
>
143+
{children}
144+
</EuiSplitButton.ActionPrimary>
145+
<EuiSplitButton.ActionSecondary
146+
iconType={secondaryButtonIcon}
147+
aria-label={secondaryButtonAriaLabel ?? ''}
148+
title={secondaryButtonTitle}
149+
onClick={onSecondaryButtonClick}
150+
isDisabled={isSecondaryButtonDisabled}
151+
isLoading={isSecondaryButtonLoading}
152+
/>
153+
</EuiSplitButton>
154+
{showNotificationIndicator && (
155+
<div
156+
data-test-subj="split-button-notification-indicator"
157+
css={{
158+
position: 'absolute' as const,
159+
top: notificationIndicatorPosition?.top ?? -10,
160+
right: notificationIndicatorPosition?.right ?? secondaryButtonWidth,
161+
left: notificationIndicatorPosition?.left,
162+
bottom: notificationIndicatorPosition?.bottom,
163+
zIndex: euiTheme.levels.flyout,
164+
pointerEvents: 'none',
165+
...(notificationIndicatorHasStroke && {
166+
'& svg': {
167+
stroke: 'white',
168+
strokeWidth: '2px',
169+
paintOrder: 'stroke fill',
170+
},
171+
}),
172+
}}
173+
>
174+
<span css={{ pointerEvents: 'auto' }}>
175+
<EuiIconTip
176+
type="dot"
177+
size={notificationIndicatorSize}
178+
color={notificationIndicatorColor}
179+
content={notifcationIndicatorTooltipContent}
180+
iconProps={{
181+
onClick: disableIndicatorOnClick
182+
? undefined
183+
: (onClick as MouseEventHandler<SVGElement> | undefined),
184+
}}
185+
/>
186+
</span>
187+
</div>
188+
)}
189+
</div>
190+
);
191+
};

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type { EuiHideForProps, IconType } from '@elastic/eui';
11-
import type { SplitButtonWithNotificationProps } from '@kbn/split-button';
11+
import type { SplitButtonWithNotificationProps } from './components/split_button_with_notification';
1212

1313
/**
1414
* Parameters passed to AppMenuRunAction

src/core/packages/chrome/app-menu/core-chrome-app-menu-components/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"jest",
77
"node",
88
"react",
9+
"@kbn/ambient-ui-types",
910
]
1011
},
1112
"include": [
@@ -16,7 +17,6 @@
1617
"target/**/*"
1718
],
1819
"kbn_references": [
19-
"@kbn/split-button",
2020
"@kbn/i18n",
2121
"@kbn/router-utils"
2222
]

src/platform/packages/private/kbn-split-button/index.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,3 @@
99

1010
export type { SplitButtonProps } from './src/split_button';
1111
export { SplitButton } from './src/split_button';
12-
13-
export type { SplitButtonWithNotificationProps } from './src/split_button_with_notification';
14-
export { SplitButtonWithNotification } from './src/split_button_with_notification';

0 commit comments

Comments
 (0)