diff --git a/src/core/packages/chrome/layout/core-chrome-layout-components/header/layout_header.tsx b/src/core/packages/chrome/layout/core-chrome-layout-components/header/layout_header.tsx index 20172d40e8acb..b9871184d03e6 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout-components/header/layout_header.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout-components/header/layout_header.tsx @@ -8,6 +8,7 @@ */ import React, { type ReactNode } from 'react'; +import { euiIncludeSelectorInFocusTrap } from '@kbn/core-chrome-layout-constants'; import { styles } from './layout_header.styles'; export interface LayoutHeaderProps { @@ -22,7 +23,12 @@ export interface LayoutHeaderProps { */ export const LayoutHeader = ({ children }: LayoutHeaderProps) => { return ( -
+
{children}
); diff --git a/src/core/packages/chrome/layout/core-chrome-layout-components/navigation/layout_navigation.tsx b/src/core/packages/chrome/layout/core-chrome-layout-components/navigation/layout_navigation.tsx index 7ed2fb218403c..5a9429c12fe49 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout-components/navigation/layout_navigation.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout-components/navigation/layout_navigation.tsx @@ -8,6 +8,7 @@ */ import React, { type ReactNode } from 'react'; +import { euiIncludeSelectorInFocusTrap } from '@kbn/core-chrome-layout-constants'; import { styles } from './layout_navigation.styles'; export interface LayoutNavigationProps { @@ -26,6 +27,7 @@ export const LayoutNavigation = ({ children }: LayoutNavigationProps) => { css={styles.root} className="kbnChromeLayoutNavigation" data-test-subj="kbnChromeLayoutNavigation" + {...euiIncludeSelectorInFocusTrap.prop} > {children}
diff --git a/src/core/packages/chrome/layout/core-chrome-layout-constants/index.ts b/src/core/packages/chrome/layout/core-chrome-layout-constants/index.ts index a8a57c0bce7a3..503bceaa4bae0 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout-constants/index.ts +++ b/src/core/packages/chrome/layout/core-chrome-layout-constants/index.ts @@ -30,3 +30,14 @@ export const MAIN_CONTENT_SELECTORS = [ '[role="main"]', // Fallback for plugins using deprecated EuiPageContent '.kbnAppWrapper', // Last-ditch fallback for all plugins regardless of page template ]; + +/** + * The selector for elements that should be included in the focus trap of a flyout. + * This will allow the flyout focus trap to include header and sidenav by default. + */ +export const euiIncludeSelectorInFocusTrap = { + prop: { + 'data-eui-includes-in-flyout-focus-trap': true, + }, + selector: `[data-eui-includes-in-flyout-focus-trap="true"]`, +}; diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx index 1b0ec07b85da2..b06406c3abaec 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_global_app_style.tsx @@ -15,11 +15,6 @@ import { APP_MAIN_SCROLL_CONTAINER_ID, } from '@kbn/core-chrome-layout-constants'; import { CommonGlobalAppStyles } from '../common/global_app_styles'; -import { - useHackSyncPushFlyout, - hackEuiPushFlyoutPaddingInlineEnd, - hackEuiPushFlyoutPaddingInlineStart, -} from './hack_use_sync_push_flyout'; const globalLayoutStyles = (euiTheme: UseEuiTheme['euiTheme']) => css` :root { @@ -107,8 +102,8 @@ const globalTempHackStyles = (euiTheme: UseEuiTheme['euiTheme']) => css` // push flyout should be pushing the application area, instead of body #${APP_MAIN_SCROLL_CONTAINER_ID} { - ${logicalCSS('padding-right', `var(${hackEuiPushFlyoutPaddingInlineEnd}, 0px)`)}; - ${logicalCSS('padding-left', `var(${hackEuiPushFlyoutPaddingInlineStart}, 0px)`)}; + ${logicalCSS('padding-right', `var(--euiPushFlyoutOffsetInlineEnd, 0px)`)}; + ${logicalCSS('padding-left', `var(--euiPushFlyoutOffsetInlineStart, 0px)`)}; } // this is a temporary hack to override EUI's body padding with push flyout .kbnBody { @@ -119,7 +114,6 @@ const globalTempHackStyles = (euiTheme: UseEuiTheme['euiTheme']) => css` export const GridLayoutGlobalStyles = () => { const { euiTheme } = useEuiTheme(); - useHackSyncPushFlyout(); return ( <> diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/hack_use_sync_push_flyout.tsx b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/hack_use_sync_push_flyout.tsx deleted file mode 100644 index 972992644b45f..0000000000000 --- a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/hack_use_sync_push_flyout.tsx +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side - * Public License v 1"; you may not use this file except in compliance with, at - * your election, the "Elastic License 2.0", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import { useEffect } from 'react'; -import { useEuiThemeCSSVariables } from '@elastic/eui'; - -export const hackEuiPushFlyoutPaddingInlineEnd = '--eui-push-flyout-padding-inline-end'; -export const hackEuiPushFlyoutPaddingInlineStart = '--eui-push-flyout-padding-inline-start'; - -/** - * This is definitely a hack for experimental purposes. - * Currently, EUI push flyouts visually push the content to the right by adding a padding to the body - * This hook listens to styles changes on the body and updates a CSS variable that is used to push the workspace content - * https://github.com/elastic/eui/issues/8820 - */ -export function useHackSyncPushFlyout() { - const { setGlobalCSSVariables } = useEuiThemeCSSVariables(); - - useEffect(() => { - const targetNode = document.body; - - const callback: MutationCallback = (mutationsList: MutationRecord[]) => { - for (const mutation of mutationsList) { - if (mutation.type === 'attributes' && mutation.attributeName === 'style') { - // const newPaddingInlineStart = window.getComputedStyle(targetNode).paddingInlineStart; - const styleAttribute = targetNode.getAttribute('style') ?? ''; - - function parseCSSDeclaration(styleAttr: string) { - return styleAttr - .trim() - .split(';') - .filter((s) => s !== '') - .map((declaration) => { - const colonIndex = declaration.indexOf(':'); - if (colonIndex === -1) { - throw new Error('Invalid CSS declaration: no colon found.'); - } - - const property = declaration.slice(0, colonIndex).trim(); - const value = declaration.slice(colonIndex + 1).trim(); - - return { property, value }; - }); - } - - const parsedStyle = parseCSSDeclaration(styleAttribute); - const paddingInline = parsedStyle.find( - (style) => style.property === 'padding-inline' - )?.value; - let paddingInlineStart = parsedStyle.find( - (style) => style.property === 'padding-inline-start' - )?.value; - let paddingInlineEnd = parsedStyle.find( - (style) => style.property === 'padding-inline-end' - )?.value; - - const [start, end] = paddingInline?.split(' ') ?? ['', '']; - - paddingInlineStart = paddingInlineStart ?? start; - paddingInlineEnd = paddingInlineEnd ?? end; - - if (paddingInlineStart) { - setGlobalCSSVariables({ - [hackEuiPushFlyoutPaddingInlineStart]: paddingInlineStart, - }); - } else { - setGlobalCSSVariables({ - [hackEuiPushFlyoutPaddingInlineStart]: null, - }); - } - - if (paddingInlineEnd) { - setGlobalCSSVariables({ - [hackEuiPushFlyoutPaddingInlineEnd]: paddingInlineEnd, - }); - } else { - setGlobalCSSVariables({ - [hackEuiPushFlyoutPaddingInlineEnd]: null, - }); - } - } - } - }; - - const observer = new MutationObserver(callback); - - observer.observe(targetNode, { attributes: true, attributeFilter: ['style'] }); - - return () => { - observer.disconnect(); - }; - }, [setGlobalCSSVariables]); -} diff --git a/src/platform/packages/shared/react/kibana_context/root/eui_provider.test.tsx b/src/platform/packages/shared/react/kibana_context/root/eui_provider.test.tsx index 11d83f0affc41..774f86d8ed9bf 100644 --- a/src/platform/packages/shared/react/kibana_context/root/eui_provider.test.tsx +++ b/src/platform/packages/shared/react/kibana_context/root/eui_provider.test.tsx @@ -7,20 +7,29 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ReactWrapper } from 'enzyme'; import type { FC } from 'react'; import React, { useEffect } from 'react'; import { act } from 'react-dom/test-utils'; +import { render, waitFor } from '@testing-library/react'; import { BehaviorSubject, of } from 'rxjs'; -import { useEuiTheme } from '@elastic/eui'; +import { useEuiTheme, EuiProvider } from '@elastic/eui'; import type { UserProfileService } from '@kbn/core-user-profile-browser'; import { userProfileServiceMock } from '@kbn/core-user-profile-browser-mocks'; import type { KibanaTheme } from '@kbn/react-kibana-context-common'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { euiIncludeSelectorInFocusTrap } from '@kbn/core-chrome-layout-constants'; import { KibanaEuiProvider } from './eui_provider'; +// Mock the EuiProvider component to capture its props +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + EuiProvider: jest.fn(original.EuiProvider), + }; +}); + describe('KibanaEuiProvider', () => { let euiTheme: ReturnType | undefined; let userProfile: UserProfileService; @@ -30,18 +39,9 @@ describe('KibanaEuiProvider', () => { euiTheme = undefined; userProfile = userProfileServiceMock.createStart(); consoleWarnMock = jest.spyOn(global.console, 'warn').mockImplementation(() => {}); + (EuiProvider as jest.Mock).mockImplementation(jest.requireActual('@elastic/eui').EuiProvider); }); - const flushPromises = async () => { - await new Promise(async (resolve, reject) => { - try { - setImmediate(() => resolve()); - } catch (error) { - reject(error); - } - }); - }; - const InnerComponent: FC = () => { const theme = useEuiTheme(); useEffect(() => { @@ -50,17 +50,10 @@ describe('KibanaEuiProvider', () => { return
foo
; }; - const refresh = async (wrapper: ReactWrapper) => { - await act(async () => { - await flushPromises(); - wrapper.update(); - }); - }; - it('exposes the EUI theme provider', async () => { const coreTheme: KibanaTheme = { darkMode: true, name: 'amsterdam' }; - const wrapper = mountWithIntl( + render( { ); - await refresh(wrapper); + // Wait for the component to update + await waitFor(() => { + expect(euiTheme).toBeDefined(); + }); expect(euiTheme!.colorMode).toEqual('DARK'); expect(euiTheme!.euiTheme.breakpoint.xxl).toEqual(1600); @@ -80,23 +76,50 @@ describe('KibanaEuiProvider', () => { it('propagates changes of the coreTheme observable', async () => { const coreTheme$ = new BehaviorSubject({ darkMode: true, name: 'amsterdam' }); - const wrapper = mountWithIntl( + render( ); - await refresh(wrapper); + // Wait for the component to update with initial theme + await waitFor(() => { + expect(euiTheme).toBeDefined(); + }); expect(euiTheme!.colorMode).toEqual('DARK'); - await act(async () => { + // Update the theme + act(() => { coreTheme$.next({ darkMode: false, name: 'amsterdam' }); }); - await refresh(wrapper); + // Wait for the component to update with new theme + await waitFor(() => { + expect(euiTheme!.colorMode).toEqual('LIGHT'); + }); - expect(euiTheme!.colorMode).toEqual('LIGHT'); expect(consoleWarnMock).not.toBeCalled(); }); + + it('passes component defaults to EuiProvider', async () => { + const coreTheme: KibanaTheme = { darkMode: true, name: 'amsterdam' }; + + render( + +
test
+
+ ); + + expect(EuiProvider).toHaveBeenCalledWith( + expect.objectContaining({ + componentDefaults: { + EuiFlyout: { + includeSelectorInFocusTrap: euiIncludeSelectorInFocusTrap.selector, + }, + }, + }), + expect.anything() + ); + }); }); diff --git a/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx b/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx index 014453389581d..3b2e0e7d2df46 100644 --- a/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx +++ b/src/platform/packages/shared/react/kibana_context/root/eui_provider.tsx @@ -12,6 +12,10 @@ import React, { FC, PropsWithChildren, useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; import createCache from '@emotion/cache'; +// We can't use the import directly because the package isn't included in the shared bundle, so below the value is hardcoded. +// However, we import this directly in the test to ensure our hardcoded selector is correct. +// import { euiIncludeSelectorInFocusTrap } from '@kbn/core-chrome-layout-constants'; + import { EuiProvider, EuiProviderProps, euiStylisPrefixer } from '@elastic/eui'; import { EUI_STYLES_GLOBAL, EUI_STYLES_UTILS } from '@kbn/core-base-common'; import { @@ -73,6 +77,12 @@ utilitiesCache.compat = true; const cache = { default: emotionCache, global: globalCache, utility: utilitiesCache }; +const componentDefaults: EuiProviderProps['componentDefaults'] = { + EuiFlyout: { + includeSelectorInFocusTrap: `[data-eui-includes-in-flyout-focus-trap="true"]`, + }, +}; + /** * Prepares and returns a configured `EuiProvider` for use in Kibana roots. In most cases, this utility context * should not be used. Instead, refer to `KibanaRootContextProvider` to set up the root of Kibana. @@ -130,6 +140,7 @@ export const KibanaEuiProvider: FC> = utilityClasses: globalStyles, highContrastMode, theme: _theme, + componentDefaults, }} > {children} diff --git a/src/platform/packages/shared/react/kibana_context/root/root_provider.test.tsx b/src/platform/packages/shared/react/kibana_context/root/root_provider.test.tsx index 52af9d5654216..922640626c894 100644 --- a/src/platform/packages/shared/react/kibana_context/root/root_provider.test.tsx +++ b/src/platform/packages/shared/react/kibana_context/root/root_provider.test.tsx @@ -9,11 +9,10 @@ import React, { FC, useEffect } from 'react'; import { act } from 'react-dom/test-utils'; -import type { ReactWrapper } from 'enzyme'; +import { render, waitFor } from '@testing-library/react'; import { of, BehaviorSubject } from 'rxjs'; import { useEuiTheme } from '@elastic/eui'; import type { UseEuiTheme } from '@elastic/eui'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; import type { KibanaTheme } from '@kbn/react-kibana-context-common'; import type { ExecutionContextStart } from '@kbn/core-execution-context-browser'; import { executionContextServiceMock } from '@kbn/core-execution-context-browser-mocks'; @@ -36,16 +35,6 @@ describe('KibanaRootContextProvider', () => { executionContext = executionContextServiceMock.createStartContract(); }); - const flushPromises = async () => { - await new Promise(async (resolve, reject) => { - try { - setImmediate(() => resolve()); - } catch (error) { - reject(error); - } - }); - }; - const InnerComponent: FC = () => { const theme = useEuiTheme(); useEffect(() => { @@ -54,17 +43,10 @@ describe('KibanaRootContextProvider', () => { return
foo
; }; - const refresh = async (wrapper: ReactWrapper) => { - await act(async () => { - await flushPromises(); - wrapper.update(); - }); - }; - it('exposes the EUI theme provider', async () => { const coreTheme: KibanaTheme = { darkMode: true, name: 'amsterdam' }; - const wrapper = mountWithIntl( + render( { ); - await refresh(wrapper); + await waitFor(() => { + expect(euiTheme).toBeDefined(); + }); expect(euiTheme!.colorMode).toEqual('DARK'); }); @@ -83,7 +67,7 @@ describe('KibanaRootContextProvider', () => { it('propagates changes of the coreTheme observable', async () => { const coreTheme$ = new BehaviorSubject({ darkMode: true, name: 'amsterdam' }); - const wrapper = mountWithIntl( + render( { ); - await refresh(wrapper); + // Wait for initial render with dark theme + await waitFor(() => { + expect(euiTheme).toBeDefined(); + }); expect(euiTheme!.colorMode).toEqual('DARK'); - await act(async () => { + // Update theme to light mode + act(() => { coreTheme$.next({ darkMode: false, name: 'amsterdam' }); }); - await refresh(wrapper); - - expect(euiTheme!.colorMode).toEqual('LIGHT'); + // Wait for the theme to update + await waitFor(() => { + expect(euiTheme!.colorMode).toEqual('LIGHT'); + }); }); }); diff --git a/src/platform/packages/shared/react/kibana_context/root/tsconfig.json b/src/platform/packages/shared/react/kibana_context/root/tsconfig.json index b934a5be91352..28b16b5ba6eb5 100644 --- a/src/platform/packages/shared/react/kibana_context/root/tsconfig.json +++ b/src/platform/packages/shared/react/kibana_context/root/tsconfig.json @@ -16,7 +16,6 @@ "target/**/*" ], "kbn_references": [ - "@kbn/test-jest-helpers", "@kbn/react-kibana-context-common", "@kbn/core-i18n-browser-mocks", "@kbn/core-i18n-browser", @@ -26,6 +25,7 @@ "@kbn/core-user-profile-browser-mocks", "@kbn/core-execution-context-browser", "@kbn/core-execution-context-browser-mocks", - "@kbn/shared-ux-router" + "@kbn/shared-ux-router", + "@kbn/core-chrome-layout-constants" ] }