Skip to content

Commit 593f4db

Browse files
committed
[Flyout System] Add flyout menu display modes
1 parent 92d7b8f commit 593f4db

10 files changed

Lines changed: 502 additions & 48 deletions

File tree

packages/eui/src/components/flyout/const.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ export const FLYOUT_SIZES = ['s', 'm', 'l', 'fill'] as const;
2323
/** Type representing a supported named flyout size. */
2424
export type EuiFlyoutSize = (typeof FLYOUT_SIZES)[number];
2525

26+
/** Menu display mode: always render menu when flyoutMenuProps is provided. */
27+
export const MENU_DISPLAY_ALWAYS = 'always' as const;
28+
/** Menu display mode: only render menu when it has content (back button, history, title, or custom actions). */
29+
export const MENU_DISPLAY_AUTO = 'auto' as const;
30+
/** Menu display mode: never render menu, show standalone close button instead. */
31+
export const MENU_DISPLAY_HIDDEN = 'hidden' as const;
32+
/** Allowed flyout menu display modes. */
33+
export const FLYOUT_MENU_DISPLAY_MODES = [
34+
MENU_DISPLAY_ALWAYS,
35+
MENU_DISPLAY_AUTO,
36+
MENU_DISPLAY_HIDDEN,
37+
] as const;
38+
/** Type representing a supported flyout menu display mode. */
39+
export type EuiFlyoutMenuDisplayMode =
40+
(typeof FLYOUT_MENU_DISPLAY_MODES)[number];
41+
2642
/** Allowed padding sizes for flyout content. */
2743
export const FLYOUT_PADDING_SIZES = ['none', 's', 'm', 'l'] as const;
2844
/** Type representing a supported flyout padding size. */
@@ -38,6 +54,9 @@ export const DEFAULT_SIDE: _EuiFlyoutSide = 'right';
3854
export const DEFAULT_SIZE: EuiFlyoutSize = 'm';
3955
/** Default padding size inside flyouts. */
4056
export const DEFAULT_PADDING_SIZE: _EuiFlyoutPaddingSize = 'l';
57+
/** Default flyout menu display mode. */
58+
export const DEFAULT_MENU_DISPLAY_MODE: EuiFlyoutMenuDisplayMode =
59+
MENU_DISPLAY_ALWAYS;
4160

4261
/**
4362
* Custom type checker for named flyout sizes since the prop

packages/eui/src/components/flyout/flyout.component.tsx

Lines changed: 42 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@ import {
6767
_EuiFlyoutPaddingSize,
6868
_EuiFlyoutSide,
6969
_EuiFlyoutType,
70+
DEFAULT_MENU_DISPLAY_MODE,
7071
DEFAULT_PADDING_SIZE,
7172
DEFAULT_PUSH_MIN_BREAKPOINT,
7273
DEFAULT_SIDE,
7374
DEFAULT_SIZE,
7475
DEFAULT_TYPE,
76+
EuiFlyoutMenuDisplayMode,
7577
EuiFlyoutSize,
7678
isEuiFlyoutSizeNamed,
7779
} from './const';
@@ -84,6 +86,7 @@ import type { EuiFlyoutCloseEvent } from './types';
8486
import { useEuiFlyoutZIndex } from './use_flyout_z_index';
8587
import { EuiFlyoutParentProvider } from './flyout_parent_context';
8688
import { useCurrentFlyoutZIndexRef } from './manager/selectors';
89+
import { useEuiFlyoutMenu } from './use_flyout_menu';
8790

8891
interface _EuiFlyoutComponentProps {
8992
/**
@@ -200,9 +203,22 @@ interface _EuiFlyoutComponentProps {
200203
/**
201204
* Props for the flyout menu to have one rendered in the flyout.
202205
* If used, the close button will be automatically hidden, as the flyout menu has its own close button.
206+
*
207+
* Use `flyoutMenuDisplayMode` to control whether/when the menu is rendered. See {@link EuiFlyoutMenuDisplayMode}.
203208
*/
204209
flyoutMenuProps?: EuiFlyoutMenuProps;
205210

211+
/**
212+
* Controls the display mode of the flyout menu:
213+
* - `'always'`: Render the menu whenever menu props are available. This may result in a close-only menu.
214+
* - `'auto'`: Render the menu whenever menu props are available and there is navigation content (back button, history, custom actions)
215+
* or a visible title.
216+
* - `'hidden'`: Never render the menu (the flyout close button will be used instead).
217+
*
218+
* @default 'always'
219+
*/
220+
flyoutMenuDisplayMode?: EuiFlyoutMenuDisplayMode;
221+
206222
/**
207223
* Whether the flyout should be resizable.
208224
* @default false
@@ -242,6 +258,7 @@ export const EuiFlyoutComponent = forwardRef(
242258
as,
243259
hideCloseButton = false,
244260
flyoutMenuProps: _flyoutMenuProps,
261+
flyoutMenuDisplayMode = DEFAULT_MENU_DISPLAY_MODE,
245262
closeButtonProps,
246263
closeButtonPosition = 'inside',
247264
onClose,
@@ -317,6 +334,20 @@ export const EuiFlyoutComponent = forwardRef(
317334
size: _size,
318335
});
319336

337+
const {
338+
flyoutMenuId,
339+
flyoutMenuProps,
340+
flyoutMenuHideTitle,
341+
shouldRenderMenu,
342+
ariaLabelledBy,
343+
} = useEuiFlyoutMenu({
344+
flyoutMenuProps: _flyoutMenuProps,
345+
flyoutMenuDisplayMode,
346+
flyoutId,
347+
currentSession,
348+
ariaLabelledBy: _ariaLabelledBy,
349+
});
350+
320351
/**
321352
* Setting up the refs on the actual flyout element in order to
322353
* accommodate for the `isPushed` state by adding padding to the body equal to the width of the element
@@ -655,34 +686,6 @@ export const EuiFlyoutComponent = forwardRef(
655686
[hasOverlayMask, descriptionId, focusTrapShards.length]
656687
);
657688

658-
/*
659-
* If the flyout menu is to be rendered, ensure the flyout has aria-labelledby referencing the menu's titleId
660-
*/
661-
const generatedMenuId = useGeneratedHtmlId();
662-
const { titleId: _titleId, ...flyoutMenuProps } = _flyoutMenuProps || {};
663-
const hasMenu = !!_flyoutMenuProps;
664-
665-
const flyoutMenuId = useMemo(() => {
666-
if (!hasMenu) return undefined;
667-
return _titleId || generatedMenuId;
668-
}, [hasMenu, _titleId, generatedMenuId]);
669-
670-
// If the flyout level is LEVEL_MAIN, the title should be hidden by default
671-
const flyoutMenuHideTitle = useMemo(() => {
672-
if (!hasMenu) return undefined;
673-
if (_flyoutMenuProps?.hideTitle !== undefined) {
674-
return _flyoutMenuProps.hideTitle;
675-
}
676-
return currentSession?.mainFlyoutId === flyoutId;
677-
}, [hasMenu, _flyoutMenuProps, currentSession, flyoutId]);
678-
679-
const ariaLabelledBy = useMemo(() => {
680-
if (flyoutMenuId) {
681-
return classnames(flyoutMenuId, _ariaLabelledBy);
682-
}
683-
return _ariaLabelledBy;
684-
}, [flyoutMenuId, _ariaLabelledBy]);
685-
686689
/*
687690
* Trap focus even when `ownFocus={false}`, otherwise closing
688691
* the flyout won't return focus to the originating button.
@@ -755,22 +758,22 @@ export const EuiFlyoutComponent = forwardRef(
755758
data-autofocus={!isPushed || undefined}
756759
onAnimationEnd={onAnimationEnd}
757760
>
758-
<EuiFlyoutParentProvider>{children}</EuiFlyoutParentProvider>
759761
{!isPushed && screenReaderDescription}
760-
{!_flyoutMenuProps && !hideCloseButton && (
761-
<EuiFlyoutCloseButton
762-
{...closeButtonProps}
763-
onClose={onClose}
764-
closeButtonPosition={closeButtonPosition}
765-
side={side}
766-
/>
767-
)}
768-
{_flyoutMenuProps && (
762+
{shouldRenderMenu ? (
769763
<EuiFlyoutMenu
770764
{...flyoutMenuProps}
771765
hideTitle={flyoutMenuHideTitle}
772766
titleId={flyoutMenuId}
773767
/>
768+
) : (
769+
!hideCloseButton && (
770+
<EuiFlyoutCloseButton
771+
{...closeButtonProps}
772+
onClose={onClose}
773+
closeButtonPosition={closeButtonPosition}
774+
side={side}
775+
/>
776+
)
774777
)}
775778
{resizable && (
776779
<EuiFlyoutResizeButton
@@ -783,6 +786,7 @@ export const EuiFlyoutComponent = forwardRef(
783786
onKeyDown={onKeyDownResizableButton}
784787
/>
785788
)}
789+
<EuiFlyoutParentProvider>{children}</EuiFlyoutParentProvider>
786790
</Element>
787791
</EuiFocusTrap>
788792
</EuiFlyoutOverlay>

packages/eui/src/components/flyout/flyout.stories.tsx

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,30 @@ import {
2020
EuiFlyoutFooter,
2121
} from './index';
2222
import { LOKI_SELECTORS } from '../../../.storybook/loki';
23-
24-
const meta: Meta<EuiFlyoutProps> = {
23+
import {
24+
DEFAULT_MENU_DISPLAY_MODE,
25+
EuiFlyoutMenuDisplayMode,
26+
FLYOUT_MENU_DISPLAY_MODES,
27+
} from './const';
28+
29+
interface FlyoutStoryArgs extends EuiFlyoutProps {
30+
onToggle?: (open: boolean) => void;
31+
flyoutMenuDisplayMode?: EuiFlyoutMenuDisplayMode;
32+
showCustomActions?: boolean;
33+
}
34+
35+
const meta: Meta<FlyoutStoryArgs> = {
2536
title: 'Layout/EuiFlyout/EuiFlyout',
2637
component: EuiFlyout,
2738
argTypes: {
2839
as: { control: 'text' },
2940
// TODO: maxWidth has multiple types
41+
flyoutMenuDisplayMode: {
42+
options: FLYOUT_MENU_DISPLAY_MODES,
43+
control: { type: 'radio' },
44+
description: 'The display mode of the flyout menu.',
45+
},
46+
showCustomActions: { control: 'boolean' },
3047
},
3148
args: {
3249
// Component defaults
@@ -40,6 +57,8 @@ const meta: Meta<EuiFlyoutProps> = {
4057
closeButtonPosition: 'inside',
4158
hideCloseButton: false,
4259
ownFocus: true,
60+
flyoutMenuDisplayMode: DEFAULT_MENU_DISPLAY_MODE,
61+
showCustomActions: true,
4362
'aria-labelledby': 'flyoutHeader',
4463
},
4564
parameters: {
@@ -55,10 +74,16 @@ type Story = StoryObj<EuiFlyoutProps>;
5574

5675
const onClose = action('onClose');
5776

58-
const StatefulFlyout = (
59-
props: Partial<EuiFlyoutProps & { onToggle: (open: boolean) => void }>
60-
) => {
61-
const { onToggle } = props;
77+
const customActions = [
78+
{
79+
iconType: 'gear',
80+
onClick: () => action('Settings clicked')(),
81+
'aria-label': 'Settings',
82+
},
83+
];
84+
85+
const StatefulFlyout = (props: Partial<FlyoutStoryArgs>) => {
86+
const { onToggle, flyoutMenuDisplayMode, showCustomActions, ...rest } = props;
6287
const [_isOpen, setIsOpen] = useState(true);
6388

6489
const handleToggle = (open: boolean) => {
@@ -73,7 +98,11 @@ const StatefulFlyout = (
7398
</EuiButton>
7499
{_isOpen && (
75100
<EuiFlyout
76-
{...props}
101+
flyoutMenuDisplayMode={flyoutMenuDisplayMode}
102+
flyoutMenuProps={{
103+
customActions: showCustomActions ? customActions : undefined,
104+
}}
105+
{...rest}
77106
onClose={() => {
78107
handleToggle(false);
79108
onClose();

packages/eui/src/components/flyout/flyout.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import {
2020
} from './flyout';
2121
import { EuiProvider } from '../provider';
2222
import { EuiFlyoutManager } from './manager';
23+
import {
24+
MENU_DISPLAY_ALWAYS,
25+
MENU_DISPLAY_AUTO,
26+
MENU_DISPLAY_HIDDEN,
27+
} from './const';
2328

2429
jest.mock('../overlay_mask', () => ({
2530
EuiOverlayMask: ({ headerZindexLocation, maskRef, ...props }: any) => (
@@ -218,6 +223,81 @@ describe('EuiFlyout', () => {
218223
});
219224
});
220225

226+
describe('flyoutMenuDisplayMode', () => {
227+
describe('always mode', () => {
228+
it('renders menu even when menu has no content', () => {
229+
const { getByTestSubject } = render(
230+
<EuiFlyout
231+
onClose={() => {}}
232+
flyoutMenuProps={{}}
233+
flyoutMenuDisplayMode={MENU_DISPLAY_ALWAYS}
234+
/>
235+
);
236+
237+
expect(getByTestSubject('euiFlyoutMenu')).toBeInTheDocument();
238+
});
239+
});
240+
241+
describe('hidden mode', () => {
242+
it('renders close button instead of menu', () => {
243+
const { getByTestSubject, queryByTestSubject } = render(
244+
<EuiFlyout
245+
onClose={() => {}}
246+
flyoutMenuProps={{
247+
title: 'Test Title',
248+
customActions: [
249+
{
250+
iconType: 'gear',
251+
onClick: () => {},
252+
'aria-label': 'Settings',
253+
},
254+
],
255+
}}
256+
flyoutMenuDisplayMode={MENU_DISPLAY_HIDDEN}
257+
/>
258+
);
259+
260+
expect(getByTestSubject('euiFlyoutCloseButton')).toBeInTheDocument();
261+
expect(queryByTestSubject('euiFlyoutMenu')).not.toBeInTheDocument();
262+
});
263+
264+
describe('auto mode', () => {
265+
it('renders menu when menu has content', () => {
266+
const { getByTestSubject } = render(
267+
<EuiFlyout
268+
onClose={() => {}}
269+
flyoutMenuProps={{
270+
customActions: [
271+
{
272+
iconType: 'gear',
273+
onClick: () => {},
274+
'aria-label': 'Settings',
275+
},
276+
],
277+
}}
278+
flyoutMenuDisplayMode={MENU_DISPLAY_AUTO}
279+
/>
280+
);
281+
282+
expect(getByTestSubject('euiFlyoutMenu')).toBeInTheDocument();
283+
});
284+
285+
it('renders close button when menu has no content', () => {
286+
const { getByTestSubject, queryByTestSubject } = render(
287+
<EuiFlyout
288+
onClose={() => {}}
289+
flyoutMenuProps={{}}
290+
flyoutMenuDisplayMode={MENU_DISPLAY_AUTO}
291+
/>
292+
);
293+
294+
expect(getByTestSubject('euiFlyoutCloseButton')).toBeInTheDocument();
295+
expect(queryByTestSubject('euiFlyoutMenu')).not.toBeInTheDocument();
296+
});
297+
});
298+
});
299+
});
300+
221301
describe('props', () => {
222302
test('hideCloseButton', () => {
223303
const { baseElement } = render(

packages/eui/src/components/flyout/flyout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
FLYOUT_PADDING_SIZES,
3131
FLYOUT_SIZES,
3232
FLYOUT_TYPES,
33+
FLYOUT_MENU_DISPLAY_MODES,
3334
} from './const';
3435

3536
export type EuiFlyoutProps<T extends ElementType = 'div' | 'nav'> = Omit<

packages/eui/src/components/flyout/flyout_menu.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,12 @@ export const EuiFlyoutMenu: FunctionComponent<EuiFlyoutMenuProps> = ({
221221
);
222222

223223
return (
224-
<div className={classes} css={styles.euiFlyoutMenu__container} {...rest}>
224+
<div
225+
className={classes}
226+
css={styles.euiFlyoutMenu__container}
227+
data-test-subj="euiFlyoutMenu"
228+
{...rest}
229+
>
225230
<EuiFlexGroup
226231
alignItems="center"
227232
justifyContent="spaceBetween"

0 commit comments

Comments
 (0)