From a8cddc2d6aed88cf72e07c3aa46e2767aed8be1e Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 24 Mar 2026 12:42:56 +0100 Subject: [PATCH 01/77] chrome-next bootstrap --- package.json | 1 + .../packages/chrome/feature-flags/README.md | 15 ++ .../packages/chrome/feature-flags/index.ts | 16 +++ .../chrome/feature-flags/jest.config.js | 14 ++ .../chrome/feature-flags/kibana.jsonc | 7 + .../chrome/feature-flags/package.json | 7 + .../chrome/feature-flags/tsconfig.json | 21 +++ .../core-chrome-layout/layout_service.ts | 2 + .../layouts/grid/grid_layout.tsx | 129 ++++++++++++------ .../layout/core-chrome-layout/tsconfig.json | 2 + tsconfig.base.json | 2 + yarn.lock | 4 + 12 files changed, 181 insertions(+), 39 deletions(-) create mode 100644 src/core/packages/chrome/feature-flags/README.md create mode 100644 src/core/packages/chrome/feature-flags/index.ts create mode 100644 src/core/packages/chrome/feature-flags/jest.config.js create mode 100644 src/core/packages/chrome/feature-flags/kibana.jsonc create mode 100644 src/core/packages/chrome/feature-flags/package.json create mode 100644 src/core/packages/chrome/feature-flags/tsconfig.json diff --git a/package.json b/package.json index c089430f1d424..33340c157a4aa 100644 --- a/package.json +++ b/package.json @@ -342,6 +342,7 @@ "@kbn/core-chrome-browser-hooks": "link:src/core/packages/chrome/browser-hooks", "@kbn/core-chrome-browser-internal": "link:src/core/packages/chrome/browser-internal", "@kbn/core-chrome-browser-internal-types": "link:src/core/packages/chrome/browser-internal-types", + "@kbn/core-chrome-feature-flags": "link:src/core/packages/chrome/feature-flags", "@kbn/core-chrome-layout": "link:src/core/packages/chrome/layout/core-chrome-layout", "@kbn/core-chrome-layout-components": "link:src/core/packages/chrome/layout/core-chrome-layout-components", "@kbn/core-chrome-layout-constants": "link:src/core/packages/chrome/layout/core-chrome-layout-constants", diff --git a/src/core/packages/chrome/feature-flags/README.md b/src/core/packages/chrome/feature-flags/README.md new file mode 100644 index 0000000000000..f1beb92afce2e --- /dev/null +++ b/src/core/packages/chrome/feature-flags/README.md @@ -0,0 +1,15 @@ +# @kbn/core-chrome-feature-flags + +Feature flag utilities for Kibana's Chrome system. + +## Usage + +```typescript +import { isNextChrome, NEXT_CHROME_FEATURE_FLAG_KEY } from '@kbn/core-chrome-feature-flags'; + +const nextChromeEnabled = isNextChrome(featureFlags); // boolean +``` + +## Feature Flags + +- **`NEXT_CHROME_FEATURE_FLAG_KEY`** (`core.chrome.next`): Enables the next-generation Chrome UI diff --git a/src/core/packages/chrome/feature-flags/index.ts b/src/core/packages/chrome/feature-flags/index.ts new file mode 100644 index 0000000000000..79d7bb5546caf --- /dev/null +++ b/src/core/packages/chrome/feature-flags/index.ts @@ -0,0 +1,16 @@ +/* + * 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 type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; + +export const NEXT_CHROME_FEATURE_FLAG_KEY = 'core.chrome.next'; + +export const isNextChrome = (featureFlags: FeatureFlagsStart): boolean => { + return featureFlags.getBooleanValue(NEXT_CHROME_FEATURE_FLAG_KEY, false); +}; diff --git a/src/core/packages/chrome/feature-flags/jest.config.js b/src/core/packages/chrome/feature-flags/jest.config.js new file mode 100644 index 0000000000000..86823820363aa --- /dev/null +++ b/src/core/packages/chrome/feature-flags/jest.config.js @@ -0,0 +1,14 @@ +/* + * 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". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/core/packages/chrome/feature-flags'], +}; diff --git a/src/core/packages/chrome/feature-flags/kibana.jsonc b/src/core/packages/chrome/feature-flags/kibana.jsonc new file mode 100644 index 0000000000000..52b728cb56877 --- /dev/null +++ b/src/core/packages/chrome/feature-flags/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/core-chrome-feature-flags", + "owner": "@elastic/appex-sharedux", + "group": "platform", + "visibility": "shared" +} diff --git a/src/core/packages/chrome/feature-flags/package.json b/src/core/packages/chrome/feature-flags/package.json new file mode 100644 index 0000000000000..4f6458ce022e2 --- /dev/null +++ b/src/core/packages/chrome/feature-flags/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/core-chrome-feature-flags", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", + "sideEffects": false +} \ No newline at end of file diff --git a/src/core/packages/chrome/feature-flags/tsconfig.json b/src/core/packages/chrome/feature-flags/tsconfig.json new file mode 100644 index 0000000000000..ce702a3c09ee9 --- /dev/null +++ b/src/core/packages/chrome/feature-flags/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-feature-flags-browser", + ] +} diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layout_service.ts b/src/core/packages/chrome/layout/core-chrome-layout/layout_service.ts index cb89dceaab1e6..3e6a4b5d10f31 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/layout_service.ts +++ b/src/core/packages/chrome/layout/core-chrome-layout/layout_service.ts @@ -13,6 +13,7 @@ import type { OverlayStart } from '@kbn/core-overlays-browser'; import type { InternalHttpStart } from '@kbn/core-http-browser-internal'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; export interface LayoutServiceStartDeps { application: InternalApplicationStart; @@ -20,6 +21,7 @@ export interface LayoutServiceStartDeps { http: InternalHttpStart; docLinks: DocLinksStart; customBranding: CustomBrandingStart; + featureFlags: FeatureFlagsStart; } /** diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx index 3cf2ec30004b1..96d117a86ae7f 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx @@ -9,7 +9,7 @@ import type { ReactNode } from 'react'; import React from 'react'; -import type { ChromeLayoutConfig } from '@kbn/core-chrome-layout-components'; +import type { ChromeLayoutConfig, ChromeStyle } from '@kbn/core-chrome-layout-components'; import { ChromeLayout, ChromeLayoutConfigProvider } from '@kbn/core-chrome-layout-components'; import { ChromeComponentsProvider, @@ -29,13 +29,18 @@ import { useSidebarWidth, useSideNavWidth, } from '@kbn/core-chrome-browser-hooks'; +import { isNextChrome } from '@kbn/core-chrome-feature-flags'; import { useGlobalFooter, useHasHeaderBanner } from '@kbn/core-chrome-browser-hooks/internal'; import { GridLayoutGlobalStyles } from './grid_global_app_style'; import type { LayoutService, LayoutServiceStartDeps } from '../../layout_service'; import { AppWrapper } from '../../app_containers'; import { APP_FIXED_VIEWPORT_ID } from '../../app_fixed_viewport'; -const layoutConfigs: { classic: ChromeLayoutConfig; project: ChromeLayoutConfig } = { +const layoutConfigs: { + classic: ChromeLayoutConfig; + project: ChromeLayoutConfig; + projectNext: ChromeLayoutConfig; +} = { classic: { chromeStyle: 'classic', headerHeight: 96, @@ -58,6 +63,77 @@ const layoutConfigs: { classic: ChromeLayoutConfig; project: ChromeLayoutConfig footerHeight: 0, navigationWidth: 0, }, + projectNext: { + chromeStyle: 'project', + headerHeight: 0, + bannerHeight: 32, + + /** The application top bar renders the app specific menu */ + /** we use it only in project style, because in classic it is included as part of the global header */ + applicationTopBarHeight: 48, + applicationMarginRight: 8, + applicationMarginBottom: 8, + sidebarWidth: 0, + footerHeight: 0, + navigationWidth: 0, + }, +}; + +interface ChromeSlots { + chromeVisible: boolean; + chromeStyle: ChromeStyle; + layoutConfig: ChromeLayoutConfig; + footer: ReactNode; + header?: ReactNode; + navigation?: ReactNode; + banner?: ReactNode; + applicationTopBar?: ReactNode; +} + +const useChromeSlots = (nextChrome: boolean): ChromeSlots => { + const chromeVisible = useIsChromeVisible(); + const chromeStyle = useChromeStyle(); + const hasAppMenu = useHasAppMenu(); + const hasHeaderBanner = useHasHeaderBanner(); + const footer = useGlobalFooter(); + const sidebarWidth = useSidebarWidth(); + const navigationWidth = useSideNavWidth(); + + const layoutConfigKey = + chromeStyle === 'classic' ? 'classic' : nextChrome ? 'projectNext' : 'project'; + + const layoutConfig: ChromeLayoutConfig = { + ...layoutConfigs[layoutConfigKey], + sidebarWidth, + navigationWidth, + }; + + const banner = hasHeaderBanner ? : undefined; + + const base = { chromeVisible, chromeStyle, layoutConfig, footer, banner }; + + if (!chromeVisible) { + return base; + } + + if (chromeStyle === 'classic') { + return { ...base, header: }; + } + + if (nextChrome) { + return { + ...base, + navigation: , + applicationTopBar: <>Chrome Next..., + }; + } + + return { + ...base, + header: , + navigation: , + applicationTopBar: hasAppMenu ? : undefined, + }; }; /** @@ -70,10 +146,11 @@ export class GridLayout implements LayoutService { * Returns a layout component with the provided dependencies */ public getComponent(): React.ComponentType { - const { application, overlays, http, docLinks, customBranding } = this.deps; + const { application, overlays, http, docLinks, customBranding, featureFlags } = this.deps; const appComponent = application.getComponent(); const appBannerComponent = overlays.banners.getComponent(); + const nextChrome = isNextChrome(featureFlags); const componentDeps: ChromeComponentsDeps = { application, @@ -83,42 +160,16 @@ export class GridLayout implements LayoutService { }; const GridLayoutContent = React.memo(() => { - const chromeVisible = useIsChromeVisible(); - const hasHeaderBanner = useHasHeaderBanner(); - const chromeStyle = useChromeStyle(); - const hasAppMenu = useHasAppMenu(); - const footer = useGlobalFooter(); - const sidebarWidth = useSidebarWidth(); - const navigationWidth = useSideNavWidth(); - - const layoutConfig = { - ...layoutConfigs[chromeStyle], - sidebarWidth, - navigationWidth, - }; - - // Assign main layout parts first - let header: ReactNode; - let navigation: ReactNode; - let banner: ReactNode; - let applicationTopBar: ReactNode; - - if (chromeVisible) { - if (chromeStyle === 'classic') { - header = ; - } else { - header = ; - if (hasAppMenu) { - applicationTopBar = ; - } - - navigation = ; - } - } - - if (hasHeaderBanner) { - banner = ; - } + const { + chromeVisible, + chromeStyle, + layoutConfig, + footer, + header, + navigation, + banner, + applicationTopBar, + } = useChromeSlots(nextChrome); return ( <> diff --git a/src/core/packages/chrome/layout/core-chrome-layout/tsconfig.json b/src/core/packages/chrome/layout/core-chrome-layout/tsconfig.json index 0da3c72377328..889fe32ec115f 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/tsconfig.json +++ b/src/core/packages/chrome/layout/core-chrome-layout/tsconfig.json @@ -28,5 +28,7 @@ "@kbn/core-chrome-layout-constants", "@kbn/core-chrome-browser-components", "@kbn/core-chrome-browser-hooks", + "@kbn/core-feature-flags-browser", + "@kbn/core-chrome-feature-flags", ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 607d2aef297b3..8c6c42eaad6eb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -414,6 +414,8 @@ "@kbn/core-chrome-browser-internal-types/*": ["src/core/packages/chrome/browser-internal-types/*"], "@kbn/core-chrome-browser-mocks": ["src/core/packages/chrome/browser-mocks"], "@kbn/core-chrome-browser-mocks/*": ["src/core/packages/chrome/browser-mocks/*"], + "@kbn/core-chrome-feature-flags": ["src/core/packages/chrome/feature-flags"], + "@kbn/core-chrome-feature-flags/*": ["src/core/packages/chrome/feature-flags/*"], "@kbn/core-chrome-layout": ["src/core/packages/chrome/layout/core-chrome-layout"], "@kbn/core-chrome-layout/*": ["src/core/packages/chrome/layout/core-chrome-layout/*"], "@kbn/core-chrome-layout-components": ["src/core/packages/chrome/layout/core-chrome-layout-components"], diff --git a/yarn.lock b/yarn.lock index d94d0cc360b55..d3862c40e826b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5424,6 +5424,10 @@ version "0.0.0" uid "" +"@kbn/core-chrome-feature-flags@link:src/core/packages/chrome/feature-flags": + version "0.0.0" + uid "" + "@kbn/core-chrome-layout-components@link:src/core/packages/chrome/layout/core-chrome-layout-components": version "0.0.0" uid "" From 528fe2ad525ea2acb8be06b152d556a533bc5497 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 24 Mar 2026 16:38:35 +0100 Subject: [PATCH 02/77] [Chrome Next] Create projectHeader service (#259404) ## Summary This PR adds `projectHeader` service. --- .../core-chrome-app-menu-components/index.ts | 1 + .../src/index.ts | 1 + .../src/types.ts | 2 ++ .../browser-internal/src/chrome_api.tsx | 9 ++++++ .../browser-internal/src/chrome_service.tsx | 9 ++++++ .../src/services/project_header/index.ts | 10 ++++++ .../project_header/project_header_service.ts | 32 +++++++++++++++++++ .../browser-mocks/src/chrome_service.mock.ts | 3 ++ .../packages/chrome/browser/src/contracts.ts | 12 ++++++- 9 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/core/packages/chrome/browser-internal/src/services/project_header/index.ts create mode 100644 src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts index 9dfc6c78f738b..476d5c43f0f88 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts @@ -23,6 +23,7 @@ export type { AppMenuPrimaryActionItem, AppMenuPopoverItem, AppMenuSplitButtonProps, + AppMenuConfigNext, } from './src'; export { diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts index d738b52f43576..84b253be9a378 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts @@ -23,6 +23,7 @@ export type { AppMenuPrimaryActionItem, AppMenuPopoverItem, AppMenuSplitButtonProps, + AppMenuConfigNext, } from './types'; export { diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts index a49c3568c7d2a..2076d8f383a82 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts @@ -284,3 +284,5 @@ export interface AppMenuConfig { */ secondaryActionItem?: AppMenuSecondaryActionItem; } + +export type AppMenuConfigNext = AppMenuConfig; diff --git a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx index d25f24e25ced5..20cb3c3ac188c 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx @@ -18,11 +18,13 @@ import type { ChromeState } from './state/chrome_state'; import type { NavControlsService } from './services/nav_controls'; import type { NavLinksService } from './services/nav_links'; import type { ProjectNavigationService } from './services/project_navigation'; +import type { ProjectHeaderService } from './services/project_header'; import type { DocTitleService } from './services/doc_title'; type NavControlsStart = ReturnType; type NavLinksStart = ReturnType; type ProjectNavigationStart = ReturnType; +type ProjectHeaderStart = ReturnType; type DocTitleStart = ReturnType; type RecentlyAccessedStart = ReturnType; @@ -34,6 +36,7 @@ export interface ChromeApiDeps { recentlyAccessed: RecentlyAccessedStart; docTitle: DocTitleStart; projectNavigation: ProjectNavigationStart; + projectHeader: ProjectHeaderStart; }; sidebar: SidebarStart; } @@ -166,6 +169,12 @@ export function createChromeApi({ state, services, sidebar }: ChromeApiDeps): In >, getActiveSolutionNavId: () => projectNavigation.getActiveSolutionNavId(), project, + + // Project Header + projectHeader: { + setAppMenu: services.projectHeader.setAppMenu, + }, + sidebar, }; diff --git a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx index 0d2b4bb6473f0..2a54ee4c48dc6 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx @@ -30,6 +30,7 @@ import { DocTitleService } from './services/doc_title'; import { NavControlsService } from './services/nav_controls'; import { NavLinksService } from './services/nav_links'; import { ProjectNavigationService } from './services/project_navigation'; +import { ProjectHeaderService } from './services/project_header'; import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; import type { InternalChromeSetup, InternalChromeStart } from './types'; import { createChromeState } from './state'; @@ -75,6 +76,7 @@ export class ChromeService { private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); private readonly projectNavigation: ProjectNavigationService; + private readonly projectHeader: ProjectHeaderService; private readonly sidebar: SidebarService; private readonly logger: Logger; private readonly isServerless: boolean; @@ -83,6 +85,7 @@ export class ChromeService { this.logger = params.coreContext.logger.get('chrome-browser'); this.isServerless = params.coreContext.env.packageInfo.buildFlavor === 'serverless'; this.projectNavigation = new ProjectNavigationService(this.isServerless); + this.projectHeader = new ProjectHeaderService(); this.sidebar = new SidebarService({ basePath: params.basePath }); } @@ -164,6 +167,10 @@ export class ChromeService { chromeBreadcrumbs$: state.breadcrumbs.classic.$, }); + const projectHeader = this.projectHeader.start({ + setAppMenu: state.appMenu.set, + }); + const sidebar = this.sidebar.start(); // 5. Setup app change handler (resets chrome state on app navigation) @@ -183,6 +190,7 @@ export class ChromeService { recentlyAccessed, docTitle, projectNavigation, + projectHeader, }, sidebar, }); @@ -194,6 +202,7 @@ export class ChromeService { this.navControls.stop(); this.navLinks.stop(); this.projectNavigation.stop(); + this.projectHeader.stop(); this.sidebar.stop(); this.stop$.next(); } diff --git a/src/core/packages/chrome/browser-internal/src/services/project_header/index.ts b/src/core/packages/chrome/browser-internal/src/services/project_header/index.ts new file mode 100644 index 0000000000000..3dfb5d216fb89 --- /dev/null +++ b/src/core/packages/chrome/browser-internal/src/services/project_header/index.ts @@ -0,0 +1,10 @@ +/* + * 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". + */ + +export { ProjectHeaderService } from './project_header_service'; diff --git a/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts b/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts new file mode 100644 index 0000000000000..2da1f5838a9a3 --- /dev/null +++ b/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts @@ -0,0 +1,32 @@ +/* + * 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 type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; +import { ReplaySubject } from 'rxjs'; + +interface StartDeps { + setAppMenu: (config?: AppMenuConfigNext) => void; +} + +export class ProjectHeaderService { + private readonly stop$ = new ReplaySubject(1); + + public start({ setAppMenu }: StartDeps) { + return { + setAppMenu: (config?: AppMenuConfigNext) => { + setAppMenu(config); + }, + }; + } + + public stop() { + this.stop$.next(); + this.stop$.complete(); + } +} diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index e0e1fa57d1de8..ca60cde816e75 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -98,6 +98,9 @@ const createStartContractMock = () => { getNavigation$: jest.fn().mockReturnValue(new BehaviorSubject({} as any)), getProjectHome$: jest.fn().mockReturnValue(of('/')), }), + projectHeader: lazyObject({ + setAppMenu: jest.fn(), + }), setGlobalFooter: jest.fn(), getGlobalFooter$: jest.fn().mockReturnValue(new BehaviorSubject(null)), getAppMenu$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), diff --git a/src/core/packages/chrome/browser/src/contracts.ts b/src/core/packages/chrome/browser/src/contracts.ts index 07d676e9abd9a..4c9fd6827d9b5 100644 --- a/src/core/packages/chrome/browser/src/contracts.ts +++ b/src/core/packages/chrome/browser/src/contracts.ts @@ -9,7 +9,7 @@ import type { ReactNode } from 'react'; import type { Observable } from 'rxjs'; -import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import type { AppMenuConfig, AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; import type { ChromeNavLink, ChromeNavLinks } from './nav_links'; import type { ChromeRecentlyAccessed } from './recently_accessed'; import type { ChromeDocTitle } from './doc_title'; @@ -322,6 +322,16 @@ export interface ChromeStart { */ getActiveSolutionNavId(): SolutionId | null; + /** + * APIs for the project header. + */ + projectHeader: { + /** + * Set the app menu configuration for the current application. + */ + setAppMenu(config?: AppMenuConfigNext): void; + }; + /** * Used only by the rendering service and KibanaRenderingContextProvider to wrap the rendering tree in the Chrome context providers */ From 746408d1b425c4af9b4d8e4bd1a287e66a9ce991 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 24 Mar 2026 17:58:28 +0100 Subject: [PATCH 03/77] [Chrome Next] Add unified projectHeader API with ChromeProjectHeaderConfig types (#259426) ## Summary Introduces a unified `chrome.projectHeader.set(config)` API. The new `ChromeProjectHeaderConfig` consolidates page identity (title, metadata), global actions (edit, share, favorite), tabs, callouts, and app menu into one structured config object. `get$` is internal-only, consumed by Chrome layout components. State is owned by `ProjectHeaderService` and automatically reset on app navigation. ### NOTE The API is mostly auto-generated from the PRD. We just need to break ground now and see what sticks. --- .../src/shared/chrome_hooks.ts | 12 +++ .../chrome/browser-internal-types/index.ts | 11 ++ .../browser-internal/src/chrome_api.tsx | 3 +- .../browser-internal/src/chrome_service.tsx | 5 +- .../project_header/project_header_service.ts | 17 ++- .../src/side_effects/app_change_handler.ts | 8 ++ .../browser-mocks/src/chrome_service.mock.ts | 3 +- src/core/packages/chrome/browser/index.ts | 5 + .../packages/chrome/browser/src/contracts.ts | 15 ++- src/core/packages/chrome/browser/src/index.ts | 8 ++ .../chrome/browser/src/project_header.ts | 102 ++++++++++++++++++ .../packages/chrome/feature-flags/moon.yml | 55 ++++++++++ .../chrome/layout/core-chrome-layout/moon.yml | 2 + 13 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 src/core/packages/chrome/browser/src/project_header.ts create mode 100644 src/core/packages/chrome/feature-flags/moon.yml diff --git a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts index d18a0b9c7b38e..9ee588644654b 100644 --- a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts +++ b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts @@ -17,6 +17,7 @@ import type { ChromeHelpMenuLink, ChromeNavControl, ChromeNavLink, + ChromeProjectHeaderConfig, } from '@kbn/core-chrome-browser'; import type { ApplicationStart } from '@kbn/core-application-browser'; import type { MountPoint } from '@kbn/core-mount-utils-browser'; @@ -279,3 +280,14 @@ export function useHasAppMenu(): boolean { const hasAppMenuConfig = useHasAppMenuConfig(); return hasLegacyActionMenu || hasAppMenuConfig; } + +/** + * Returns the current project header configuration set via + * `chrome.projectHeader.set()`, or `undefined` if not set. + * Used by Chrome-Next top bar components. + */ +export function useProjectHeader(): ChromeProjectHeaderConfig | undefined { + const chrome = useChromeService(); + const config$ = useMemo(() => chrome.projectHeader.get$(), [chrome]); + return useObservable(config$, undefined); +} diff --git a/src/core/packages/chrome/browser-internal-types/index.ts b/src/core/packages/chrome/browser-internal-types/index.ts index 6b339db60594f..a4eed585080e5 100644 --- a/src/core/packages/chrome/browser-internal-types/index.ts +++ b/src/core/packages/chrome/browser-internal-types/index.ts @@ -15,6 +15,7 @@ import type { ChromeBadge, ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension, + ChromeProjectHeaderConfig, ChromeProjectNavigationNode, ChromeSetProjectBreadcrumbsParams, ChromeUserBanner, @@ -104,4 +105,14 @@ export interface InternalChromeStart extends ChromeStart { params?: Partial ): void; }; + + /** @internal Extends the public projectHeader with `get$` for Chrome layout components. */ + projectHeader: ChromeStart['projectHeader'] & { + /** + * Get an observable of the current project header configuration. + * Used by Chrome-Next layout components to render the title area, + * global actions, and app menu. + */ + get$(): Observable; + }; } diff --git a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx index 20cb3c3ac188c..2840ad4ae2d3d 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx @@ -172,7 +172,8 @@ export function createChromeApi({ state, services, sidebar }: ChromeApiDeps): In // Project Header projectHeader: { - setAppMenu: services.projectHeader.setAppMenu, + get$: services.projectHeader.get$, + set: services.projectHeader.set, }, sidebar, diff --git a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx index 2a54ee4c48dc6..59e64d4ba4225 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx @@ -167,9 +167,7 @@ export class ChromeService { chromeBreadcrumbs$: state.breadcrumbs.classic.$, }); - const projectHeader = this.projectHeader.start({ - setAppMenu: state.appMenu.set, - }); + const projectHeader = this.projectHeader.start(); const sidebar = this.sidebar.start(); @@ -179,6 +177,7 @@ export class ChromeService { stop$: this.stop$, state, docTitle, + projectHeader, }); // 6. Return chrome API diff --git a/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts b/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts index 2da1f5838a9a3..5d643a979118f 100644 --- a/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts +++ b/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts @@ -7,21 +7,20 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; import { ReplaySubject } from 'rxjs'; - -interface StartDeps { - setAppMenu: (config?: AppMenuConfigNext) => void; -} +import type { ChromeProjectHeaderConfig } from '@kbn/core-chrome-browser'; +import { createState } from '../../state/state_helpers'; export class ProjectHeaderService { private readonly stop$ = new ReplaySubject(1); + private readonly config = createState(undefined); - public start({ setAppMenu }: StartDeps) { + public start() { return { - setAppMenu: (config?: AppMenuConfigNext) => { - setAppMenu(config); - }, + get$: () => this.config.$, + set: (value?: ChromeProjectHeaderConfig) => this.config.set(value), + /** @internal Reset to initial state (e.g. on app change). */ + reset: () => this.config.set(undefined), }; } diff --git a/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts b/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts index 8c9f499d04931..59dd7ba151e80 100644 --- a/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts +++ b/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts @@ -11,12 +11,16 @@ import type { Observable } from 'rxjs'; import { takeUntil } from 'rxjs'; import type { ChromeDocTitle } from '@kbn/core-chrome-browser'; import type { ChromeState } from '../state/chrome_state'; +import type { ProjectHeaderService } from '../services/project_header'; + +type ProjectHeaderStart = ReturnType; export interface AppChangeHandlerDeps { currentAppId$: Observable; stop$: Observable; state: ChromeState; docTitle: ChromeDocTitle; + projectHeader: ProjectHeaderStart; } /** @@ -28,6 +32,7 @@ export function setupAppChangeHandler({ stop$, state, docTitle, + projectHeader, }: AppChangeHandlerDeps): void { currentAppId$.pipe(takeUntil(stop$)).subscribe(() => { // Reset UI elements @@ -43,5 +48,8 @@ export function setupAppChangeHandler({ // Reset document title docTitle.reset(); + + // Reset Chrome-Next project header + projectHeader.reset(); }); } diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index ca60cde816e75..bdfc1abf92c9e 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -99,7 +99,8 @@ const createStartContractMock = () => { getProjectHome$: jest.fn().mockReturnValue(of('/')), }), projectHeader: lazyObject({ - setAppMenu: jest.fn(), + get$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), + set: jest.fn(), }), setGlobalFooter: jest.fn(), getGlobalFooter$: jest.fn().mockReturnValue(new BehaviorSubject(null)), diff --git a/src/core/packages/chrome/browser/index.ts b/src/core/packages/chrome/browser/index.ts index dc1a6ddf8ad9b..9c66bf3926540 100644 --- a/src/core/packages/chrome/browser/index.ts +++ b/src/core/packages/chrome/browser/index.ts @@ -55,4 +55,9 @@ export type { SidebarAppDefinition, SidebarSetup, SidebarStart, + ChromeProjectHeaderConfig, + ChromeProjectHeaderMetadataItem, + ChromeProjectHeaderGlobalActions, + ChromeProjectHeaderTab, + ChromeProjectHeaderCallout, } from './src'; diff --git a/src/core/packages/chrome/browser/src/contracts.ts b/src/core/packages/chrome/browser/src/contracts.ts index 4c9fd6827d9b5..4a3cb6c3eba41 100644 --- a/src/core/packages/chrome/browser/src/contracts.ts +++ b/src/core/packages/chrome/browser/src/contracts.ts @@ -9,7 +9,8 @@ import type { ReactNode } from 'react'; import type { Observable } from 'rxjs'; -import type { AppMenuConfig, AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; +import type { ChromeProjectHeaderConfig } from './project_header'; import type { ChromeNavLink, ChromeNavLinks } from './nav_links'; import type { ChromeRecentlyAccessed } from './recently_accessed'; import type { ChromeDocTitle } from './doc_title'; @@ -323,13 +324,19 @@ export interface ChromeStart { getActiveSolutionNavId(): SolutionId | null; /** - * APIs for the project header. + * APIs for the Chrome-controlled project header (Chrome-Next). + * Provides a unified configuration surface for page identity, global + * actions, and app menu — all rendered by Chrome rather than by individual apps. */ projectHeader: { /** - * Set the app menu configuration for the current application. + * Set the project header configuration for the current page. + * Chrome renders the title, metadata, global actions, and app menu. + * + * Pass `undefined` to clear (e.g. on unmount or route change). + * Automatically cleared on app change. */ - setAppMenu(config?: AppMenuConfigNext): void; + set(config?: ChromeProjectHeaderConfig): void; }; /** diff --git a/src/core/packages/chrome/browser/src/index.ts b/src/core/packages/chrome/browser/src/index.ts index d6bc9dcaa484d..1ce6b4e67f1be 100644 --- a/src/core/packages/chrome/browser/src/index.ts +++ b/src/core/packages/chrome/browser/src/index.ts @@ -59,3 +59,11 @@ export type { SidebarSetup, SidebarStart, } from './sidebar'; + +export type { + ChromeProjectHeaderConfig, + ChromeProjectHeaderMetadataItem, + ChromeProjectHeaderGlobalActions, + ChromeProjectHeaderTab, + ChromeProjectHeaderCallout, +} from './project_header'; diff --git a/src/core/packages/chrome/browser/src/project_header.ts b/src/core/packages/chrome/browser/src/project_header.ts new file mode 100644 index 0000000000000..0d82f550a02a7 --- /dev/null +++ b/src/core/packages/chrome/browser/src/project_header.ts @@ -0,0 +1,102 @@ +/* + * 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 type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; + +/** + * Unified configuration for the Chrome-controlled project header. + * Apps provide structured data via `chrome.projectHeader.set(config)`; + * Chrome renders title area, global actions, and app menu. + */ +export interface ChromeProjectHeaderConfig { + /** + * Page title displayed in the Chrome-controlled header. + * Can be the app name (e.g. "Discover") or a viewed object + * (e.g. a saved dashboard name). Single-line, truncated by Chrome if too long. + */ + title: string; + + /** + * Optional metadata badges/text rendered below the title. + * E.g. "Managed", "Read-only", creation date, severity. + * Limited to a single row; Chrome controls layout. + */ + metadata?: ChromeProjectHeaderMetadataItem[]; + + /** + * Global object actions whose icon, label, and position are fixed by Chrome. + * Apps opt-in by providing handlers; they cannot change icon or order. + * + * Rendering order (fixed by Chrome): edit, share, favorite. + */ + globalActions?: ChromeProjectHeaderGlobalActions; + + /** + * Optional tabs rendered as part of the header. + * When provided, increases the top bar height dynamically. + */ + tabs?: ChromeProjectHeaderTab[]; + + /** + * Optional single callout rendered below the title/tabs area. + * Only one callout at a time. Chrome controls styling. + */ + callout?: ChromeProjectHeaderCallout; + + /** + * App menu (toolbar actions). Items, primary action, secondary action. + * TODO: Consider strict type independent from `@kbn/core-chrome-app-menu-components` + */ + appMenu?: AppMenuConfigNext; +} + +export interface ChromeProjectHeaderMetadataItem { + label: string; + type: 'badge' | 'text'; + /** Badge color (EUI badge color). Only used when type is 'badge'. */ + color?: string; + tooltip?: string; +} + +/** + * Global actions whose icon, label, and position are fixed by Chrome. + * Apps provide only the behavioral handlers. + */ +export interface ChromeProjectHeaderGlobalActions { + /** Edit action. Chrome renders a pencil icon next to the title. */ + edit?: { + onClick: () => void; + }; + /** Share action. Chrome renders a share icon. */ + share?: { + onClick: () => void; + }; + /** Favorite/star action. Chrome renders a star icon. */ + favorite?: { + isFavorited: boolean; + onClick: () => void; + }; +} + +export interface ChromeProjectHeaderTab { + id: string; + label: string; + isSelected?: boolean; + onClick: () => void; + /** Optional badge count on the tab */ + badge?: number; +} + +export interface ChromeProjectHeaderCallout { + title: string; + color: 'primary' | 'warning' | 'danger' | 'success'; + text?: string; + /** When provided, renders a dismiss button */ + onDismiss?: () => void; +} diff --git a/src/core/packages/chrome/feature-flags/moon.yml b/src/core/packages/chrome/feature-flags/moon.yml new file mode 100644 index 0000000000000..029baef19cd36 --- /dev/null +++ b/src/core/packages/chrome/feature-flags/moon.yml @@ -0,0 +1,55 @@ +# This file is generated by the @kbn/moon package. Any manual edits will be erased! +# To extend this, write your extensions/overrides to 'moon.extend.yml' +# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/core-chrome-feature-flags' + +$schema: https://moonrepo.dev/schemas/project.json +id: '@kbn/core-chrome-feature-flags' +layer: unknown +owners: + defaultOwner: '@elastic/appex-sharedux' +toolchains: + default: node +language: typescript +project: + title: '@kbn/core-chrome-feature-flags' + description: Moon project for @kbn/core-chrome-feature-flags + channel: '' + owner: '@elastic/appex-sharedux' + sourceRoot: src/core/packages/chrome/feature-flags +dependsOn: + - '@kbn/core-feature-flags-browser' +tags: + - shared-browser + - package + - prod + - group-platform + - shared + - jest-unit-tests +fileGroups: + src: + - '**/*.ts' + - '**/*.tsx' + - '!target/**/*' +tasks: + jest: + command: node + args: + - '--no-experimental-require-module' + - $workspaceRoot/scripts/jest + - '--config' + - $projectRoot/jest.config.js + options: + runFromWorkspaceRoot: true + inputs: + - '@group(src)' + jestCI: + command: node + args: + - '--no-experimental-require-module' + - $workspaceRoot/scripts/jest + - '--config' + - $projectRoot/jest.config.js + options: + runFromWorkspaceRoot: true + inputs: + - '@group(src)' diff --git a/src/core/packages/chrome/layout/core-chrome-layout/moon.yml b/src/core/packages/chrome/layout/core-chrome-layout/moon.yml index a8e2ed5cea9fc..bd8f9d79c1f65 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/moon.yml +++ b/src/core/packages/chrome/layout/core-chrome-layout/moon.yml @@ -27,6 +27,8 @@ dependsOn: - '@kbn/core-chrome-layout-constants' - '@kbn/core-chrome-browser-components' - '@kbn/core-chrome-browser-hooks' + - '@kbn/core-feature-flags-browser' + - '@kbn/core-chrome-feature-flags' tags: - shared-browser - package From 69815c68c4113285a33dd934682e8221e30fe9aa Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 25 Mar 2026 13:11:04 +0100 Subject: [PATCH 04/77] [Chrome Next] Basic title and menu (#259544) ## Summary Screenshot 2026-03-25 at 12 08 38 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/application_service.tsx | 9 ++- .../src/application_service.mock.ts | 2 + .../application/browser/src/contracts.ts | 7 ++ .../chrome/browser-components/index.ts | 1 + .../chrome/browser-components/src/context.tsx | 2 +- .../src/project_next/app_menu.tsx | 53 +++++++++++++ .../src/project_next/header.tsx | 52 +++++++++++++ .../src/project_next/hooks/index.ts | 13 ++++ .../project_next/hooks/use_project_header.ts | 24 ++++++ .../hooks/use_project_next_app_menu.ts | 22 ++++++ .../hooks/use_report_top_bar_height.ts | 62 ++++++++++++++++ .../src/project_next/hooks/use_title.test.tsx | 74 +++++++++++++++++++ .../src/project_next/hooks/use_title.ts | 23 ++++++ .../src/project_next/index.ts | 10 +++ .../src/shared/chrome_hooks.ts | 12 --- .../chrome/browser-components/tsconfig.json | 1 + .../layouts/grid/grid_layout.tsx | 8 +- 17 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/header.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_report_top_bar_height.ts create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts create mode 100644 src/core/packages/chrome/browser-components/src/project_next/index.ts diff --git a/src/core/packages/application/browser-internal/src/application_service.tsx b/src/core/packages/application/browser-internal/src/application_service.tsx index 583b9914e50fd..c477f4ceea0bc 100644 --- a/src/core/packages/application/browser-internal/src/application_service.tsx +++ b/src/core/packages/application/browser-internal/src/application_service.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { flushSync } from 'react-dom'; import { BehaviorSubject, firstValueFrom, type Observable, Subject, type Subscription } from 'rxjs'; -import { map, shareReplay, takeUntil, distinctUntilChanged, filter, take } from 'rxjs'; +import { map, shareReplay, switchMap, takeUntil, distinctUntilChanged, filter, take } from 'rxjs'; import type { History } from 'history'; import { createBrowserHistory } from 'history'; @@ -330,6 +330,13 @@ export class ApplicationService { distinctUntilChanged(), takeUntil(this.stop$) ), + currentAppTitle$: this.currentAppId$.pipe( + filter((appId) => appId !== undefined), + distinctUntilChanged(), + switchMap((appId) => applications$.pipe(map((apps) => apps.get(appId)?.title))), + distinctUntilChanged(), + takeUntil(this.stop$) + ), currentActionMenu$: this.currentActionMenu$.pipe( distinctUntilChanged(), takeUntil(this.stop$) diff --git a/src/core/packages/application/browser-mocks/src/application_service.mock.ts b/src/core/packages/application/browser-mocks/src/application_service.mock.ts index 31951853011e6..cf0d0e6aec5f4 100644 --- a/src/core/packages/application/browser-mocks/src/application_service.mock.ts +++ b/src/core/packages/application/browser-mocks/src/application_service.mock.ts @@ -49,6 +49,7 @@ const createStartContractMock = (): jest.Mocked => { return lazyObject({ applications$: new BehaviorSubject>(new Map()), currentAppId$: currentAppId$.asObservable(), + currentAppTitle$: new BehaviorSubject(undefined), currentLocation$: currentLocation$.asObservable(), capabilities: capabilitiesServiceMock.createStartContract().capabilities, navigateToApp: jest.fn(), @@ -92,6 +93,7 @@ const createInternalStartContractMock = ( applications$: new BehaviorSubject>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), + currentAppTitle$: new BehaviorSubject(undefined), currentLocation$: currentLocation$.asObservable(), currentActionMenu$: new BehaviorSubject(undefined), getComponent: jest.fn(), diff --git a/src/core/packages/application/browser/src/contracts.ts b/src/core/packages/application/browser/src/contracts.ts index caa336c1b37d2..827b498ddeab1 100644 --- a/src/core/packages/application/browser/src/contracts.ts +++ b/src/core/packages/application/browser/src/contracts.ts @@ -152,6 +152,13 @@ export interface ApplicationStart { */ currentAppId$: Observable; + /** + * An observable that emits the title of the currently mounted application. + * Derived from {@link ApplicationStart.currentAppId$ | currentAppId$} and + * {@link ApplicationStart.applications$ | applications$}. + */ + currentAppTitle$: Observable; + /** * An observable that emits the current path#hash and each subsequent update using the global history instance */ diff --git a/src/core/packages/chrome/browser-components/index.ts b/src/core/packages/chrome/browser-components/index.ts index 0363b8818bb99..23a63c5679c71 100644 --- a/src/core/packages/chrome/browser-components/index.ts +++ b/src/core/packages/chrome/browser-components/index.ts @@ -12,6 +12,7 @@ export type { ChromeComponentsDeps } from './src/context'; export { ClassicHeader } from './src/classic'; export { ProjectHeader } from './src/project'; +export { ProjectNextHeader } from './src/project_next'; export { GridLayoutProjectSideNav } from './src/project/sidenav/grid_layout_sidenav'; export { Sidebar } from './src/sidebar'; export { AppMenuBar } from './src/project/app_menu'; diff --git a/src/core/packages/chrome/browser-components/src/context.tsx b/src/core/packages/chrome/browser-components/src/context.tsx index c9c584e400601..1f77579dac6b4 100644 --- a/src/core/packages/chrome/browser-components/src/context.tsx +++ b/src/core/packages/chrome/browser-components/src/context.tsx @@ -17,7 +17,7 @@ import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser'; export interface ChromeComponentsDeps { application: Pick< InternalApplicationStart, - 'navigateToUrl' | 'currentAppId$' | 'currentActionMenu$' + 'navigateToUrl' | 'currentAppId$' | 'currentAppTitle$' | 'currentActionMenu$' >; http: Pick; docLinks: DocLinksStart; diff --git a/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx b/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx new file mode 100644 index 0000000000000..5bea0717f4242 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx @@ -0,0 +1,53 @@ +/* + * 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 { css } from '@emotion/react'; +import React, { lazy, Suspense } from 'react'; +import { useHasLegacyActionMenu } from '../shared/chrome_hooks'; +import { HeaderActionMenu } from '../shared/header_action_menu'; +import { useProjectNextAppMenu } from './hooks'; + +const AppMenuComponent = lazy(async () => { + const { AppMenuComponent: Component } = await import('@kbn/core-chrome-app-menu-components'); + return { default: Component }; +}); + +const styles = css` + margin-left: auto; + flex-shrink: 0; +`; + +/** + * Renders the app menu for the Chrome-Next project header. + * Fallback chain: merged AppMenuConfig -> legacy HeaderActionMenu -> nothing. + */ +export const ProjectNextAppMenu = React.memo(() => { + const appMenuConfig = useProjectNextAppMenu(); + const hasLegacyActionMenu = useHasLegacyActionMenu(); + + if (appMenuConfig) { + return ( +
+ + + +
+ ); + } + + if (hasLegacyActionMenu) { + return ( +
+ +
+ ); + } + + return null; +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/header.tsx b/src/core/packages/chrome/browser-components/src/project_next/header.tsx new file mode 100644 index 0000000000000..5c1bc404b6788 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/header.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useEuiTheme, type UseEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { useReportTopBarHeight, useTitle } from './hooks'; +import { ProjectNextAppMenu } from './app_menu'; + +const useStyles = (euiTheme: UseEuiTheme['euiTheme']) => + useMemo(() => { + const root = css` + display: flex; + align-items: center; + padding: ${euiTheme.size.s}; + background: ${euiTheme.colors.backgroundBasePlain}; + border-bottom: ${euiTheme.border.thin}; + margin-bottom: -${euiTheme.border.width.thin}; + `; + + const title = css` + font-size: ${euiTheme.size.base}; + font-weight: ${euiTheme.font.weight.semiBold}; + line-height: ${euiTheme.size.l}; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + + return { root, title }; + }, [euiTheme]); + +export const ProjectNextHeader = React.memo(() => { + const title = useTitle(); + const { euiTheme } = useEuiTheme(); + const styles = useStyles(euiTheme); + const heightRef = useReportTopBarHeight(); + + return ( +
+

{title}

+ +
+ ); +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts new file mode 100644 index 0000000000000..d6a179845c2b9 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts @@ -0,0 +1,13 @@ +/* + * 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". + */ + +export { useProjectHeader } from './use_project_header'; +export { useTitle } from './use_title'; +export { useProjectNextAppMenu } from './use_project_next_app_menu'; +export { useReportTopBarHeight } from './use_report_top_bar_height'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts new file mode 100644 index 0000000000000..309246fdffaa0 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts @@ -0,0 +1,24 @@ +/* + * 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 { useMemo } from 'react'; +import type { ChromeProjectHeaderConfig } from '@kbn/core-chrome-browser'; +import { useObservable } from '@kbn/use-observable'; +import { useChromeService } from '@kbn/core-chrome-browser-context'; + +/** + * Returns the current project header configuration set via + * `chrome.projectHeader.set()`, or `undefined` if not set. + * Used by Chrome-Next top bar components. + */ +export function useProjectHeader(): ChromeProjectHeaderConfig | undefined { + const chrome = useChromeService(); + const config$ = useMemo(() => chrome.projectHeader.get$(), [chrome]); + return useObservable(config$, undefined); +} diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts new file mode 100644 index 0000000000000..def6921eadfdc --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts @@ -0,0 +1,22 @@ +/* + * 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 type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; +import { useAppMenu } from '../../shared/chrome_hooks'; +import { useProjectHeader } from './use_project_header'; + +/** + * Returns the app menu config for the Chrome-Next project header. + * Fallback: `config.appMenu` from `projectHeader.set()` -> global `chrome.getAppMenu$()`. + */ +export function useProjectNextAppMenu(): AppMenuConfigNext | undefined { + const config = useProjectHeader(); + const globalAppMenu = useAppMenu(); + return config?.appMenu ?? globalAppMenu; +} diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_report_top_bar_height.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_report_top_bar_height.ts new file mode 100644 index 0000000000000..046223bcef46a --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_report_top_bar_height.ts @@ -0,0 +1,62 @@ +/* + * 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 { useCallback, useRef, useEffect } from 'react'; +import { useLayoutUpdate } from '@kbn/core-chrome-layout-components'; + +/** + * Measures the height of the application top bar via ResizeObserver and + * reports it to the layout system through `useLayoutUpdate`. + * + * Returns a ref callback to attach to the top bar root element. + * Reports `0` when the element is removed or on unmount. + */ +export const useReportTopBarHeight = (): ((node: HTMLElement | null) => void) => { + const updateLayout = useLayoutUpdate(); + const observerRef = useRef(null); + const lastHeightRef = useRef(0); + + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + updateLayout({ applicationTopBarHeight: 0 }); + }; + }, [updateLayout]); + + const refCallback = useCallback( + (node: HTMLElement | null) => { + observerRef.current?.disconnect(); + + if (!node) { + if (lastHeightRef.current !== 0) { + lastHeightRef.current = 0; + updateLayout({ applicationTopBarHeight: 0 }); + } + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + + const height = Math.round(entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height); + if (height !== lastHeightRef.current) { + lastHeightRef.current = height; + updateLayout({ applicationTopBarHeight: height }); + } + }); + + observer.observe(node, { box: 'border-box' }); + observerRef.current = observer; + }, + [updateLayout] + ); + + return refCallback; +}; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx new file mode 100644 index 0000000000000..b8d4d86140093 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import type { BehaviorSubject } from 'rxjs'; +import type { ChromeProjectHeaderConfig } from '@kbn/core-chrome-browser'; +import { createMockChromeComponentsDeps, TestChromeProviders } from '../../test_helpers'; +import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; +import { useTitle } from './use_title'; + +describe('useTitle', () => { + it('returns the app title from currentAppTitle$', () => { + const deps = createMockChromeComponentsDeps(); + (deps.application.currentAppTitle$ as BehaviorSubject).next('Discover'); + + const { result } = renderHook(() => useTitle(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe('Discover'); + }); + + it('returns "Unknown" when no app title is available', () => { + const deps = createMockChromeComponentsDeps(); + + const { result } = renderHook(() => useTitle(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe('Unknown'); + }); + + it('prefers projectHeader config title over app title', () => { + const deps = createMockChromeComponentsDeps(); + (deps.application.currentAppTitle$ as BehaviorSubject).next('Dashboards'); + + const chrome = chromeServiceMock.createStartContract(); + (chrome.projectHeader.get$() as BehaviorSubject).next({ + title: 'My Dashboard', + }); + + const { result } = renderHook(() => useTitle(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe('My Dashboard'); + }); + + it('updates when currentAppTitle$ emits a new value', () => { + const deps = createMockChromeComponentsDeps(); + const title$ = deps.application.currentAppTitle$ as BehaviorSubject; + title$.next('Dashboard'); + + const { result } = renderHook(() => useTitle(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toBe('Dashboard'); + + act(() => title$.next('Discover')); + expect(result.current).toBe('Discover'); + }); +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts new file mode 100644 index 0000000000000..10749b7b2c376 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts @@ -0,0 +1,23 @@ +/* + * 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 { useObservable } from '@kbn/use-observable'; +import { useChromeComponentsDeps } from '../../context'; +import { useProjectHeader } from './use_project_header'; + +/** + * Returns the display title for the Chrome-Next project header. + * Fallback chain: explicit `config.title` -> current app title -> 'Unknown'. + */ +export function useTitle(): string { + const config = useProjectHeader(); + const { application } = useChromeComponentsDeps(); + const appTitle = useObservable(application.currentAppTitle$, undefined); + return config?.title ?? appTitle ?? 'Unknown'; +} diff --git a/src/core/packages/chrome/browser-components/src/project_next/index.ts b/src/core/packages/chrome/browser-components/src/project_next/index.ts new file mode 100644 index 0000000000000..1743a03c9c3b0 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/index.ts @@ -0,0 +1,10 @@ +/* + * 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". + */ + +export { ProjectNextHeader } from './header'; diff --git a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts index 9ee588644654b..d18a0b9c7b38e 100644 --- a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts +++ b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts @@ -17,7 +17,6 @@ import type { ChromeHelpMenuLink, ChromeNavControl, ChromeNavLink, - ChromeProjectHeaderConfig, } from '@kbn/core-chrome-browser'; import type { ApplicationStart } from '@kbn/core-application-browser'; import type { MountPoint } from '@kbn/core-mount-utils-browser'; @@ -280,14 +279,3 @@ export function useHasAppMenu(): boolean { const hasAppMenuConfig = useHasAppMenuConfig(); return hasLegacyActionMenu || hasAppMenuConfig; } - -/** - * Returns the current project header configuration set via - * `chrome.projectHeader.set()`, or `undefined` if not set. - * Used by Chrome-Next top bar components. - */ -export function useProjectHeader(): ChromeProjectHeaderConfig | undefined { - const chrome = useChromeService(); - const config$ = useMemo(() => chrome.projectHeader.get$(), [chrome]); - return useObservable(config$, undefined); -} diff --git a/src/core/packages/chrome/browser-components/tsconfig.json b/src/core/packages/chrome/browser-components/tsconfig.json index 38dc478cc1910..35b90a152ab61 100644 --- a/src/core/packages/chrome/browser-components/tsconfig.json +++ b/src/core/packages/chrome/browser-components/tsconfig.json @@ -46,5 +46,6 @@ "@kbn/shared-ux-label-formatter", "@kbn/test-jest-helpers", "@kbn/use-observable", + "@kbn/core-chrome-layout-components", ] } diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx index 96d117a86ae7f..6bf80f3da59a8 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx @@ -15,6 +15,7 @@ import { ChromeComponentsProvider, ClassicHeader, ProjectHeader, + ProjectNextHeader, GridLayoutProjectSideNav, HeaderTopBanner, ChromelessHeader, @@ -68,9 +69,8 @@ const layoutConfigs: { headerHeight: 0, bannerHeight: 32, - /** The application top bar renders the app specific menu */ - /** we use it only in project style, because in classic it is included as part of the global header */ - applicationTopBarHeight: 48, + /** Height is reported dynamically by ProjectNextHeader via useReportLayoutHeight */ + applicationTopBarHeight: 0, applicationMarginRight: 8, applicationMarginBottom: 8, sidebarWidth: 0, @@ -124,7 +124,7 @@ const useChromeSlots = (nextChrome: boolean): ChromeSlots => { return { ...base, navigation: , - applicationTopBar: <>Chrome Next..., + applicationTopBar: , }; } From 34dfc0a995c6a269a21c76899ba0ee26a4be0c01 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 25 Mar 2026 14:01:43 +0100 Subject: [PATCH 05/77] [Chrome Next] add ai agent button; rename projectHeader -> next (#259567) ## Summary Screenshot 2026-03-25 at 13 33 18 --- .../src/project_next/ai_button_slot.tsx | 25 +++++++++++++ .../src/project_next/hooks/index.ts | 5 +-- .../src/project_next/hooks/use_ai_button.ts | 23 ++++++++++++ .../src/shared/chrome_hooks.ts | 12 ++++++ .../chrome/browser-internal-types/index.ts | 18 ++++----- .../browser-internal/src/chrome_api.tsx | 19 ++++++---- .../browser-internal/src/chrome_service.tsx | 14 +++---- .../{project_header => next_header}/index.ts | 2 +- .../next_header_service.ts} | 8 ++-- .../src/side_effects/app_change_handler.ts | 12 +++--- .../src/state/chrome_state.ts | 3 ++ .../browser-mocks/src/chrome_service.mock.ts | 12 ++++-- src/core/packages/chrome/browser/index.ts | 10 ++--- .../packages/chrome/browser/src/contracts.ts | 37 ++++++++++++------- src/core/packages/chrome/browser/src/index.ts | 12 +++--- .../src/{project_header.ts => next_header.ts} | 22 +++++------ .../shared/agent_builder/public/plugin.tsx | 8 ++++ 17 files changed, 165 insertions(+), 77 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts rename src/core/packages/chrome/browser-internal/src/services/{project_header => next_header}/index.ts (87%) rename src/core/packages/chrome/browser-internal/src/services/{project_header/project_header_service.ts => next_header/next_header_service.ts} (76%) rename src/core/packages/chrome/browser/src/{project_header.ts => next_header.ts} (82%) diff --git a/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx b/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx new file mode 100644 index 0000000000000..489577b63482a --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx @@ -0,0 +1,25 @@ +/* + * 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 React from 'react'; +import { useAiButton } from './hooks'; + +/** + * Renders the AI button in the Chrome-Next project header. + * The plugin owns the component; Chrome just places it in the slot. + */ +export const AiButtonSlot = React.memo(() => { + const node = useAiButton(); + + if (!node) { + return null; + } + + return <>{node}; +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts index d6a179845c2b9..e85302f90fc89 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts @@ -7,7 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { useProjectHeader } from './use_project_header'; -export { useTitle } from './use_title'; -export { useProjectNextAppMenu } from './use_project_next_app_menu'; -export { useReportTopBarHeight } from './use_report_top_bar_height'; +export { useAiButton } from './use_ai_button'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts new file mode 100644 index 0000000000000..46ada8f9ce7cf --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts @@ -0,0 +1,23 @@ +/* + * 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 type { ReactNode } from 'react'; +import { useMemo } from 'react'; +import { useObservable } from '@kbn/use-observable'; +import { useChromeService } from '@kbn/core-chrome-browser-context'; + +/** + * Returns the AI button ReactNode set via `chrome.next.aiButton.set()`, + * or `undefined` if not set. Used by the Chrome-Next header. + */ +export function useAiButton(): ReactNode | undefined { + const chrome = useChromeService(); + const node$ = useMemo(() => chrome.next.aiButton.get$(), [chrome]); + return useObservable(node$, undefined); +} diff --git a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts index d18a0b9c7b38e..71a73ec7ce4a4 100644 --- a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts +++ b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts @@ -17,6 +17,7 @@ import type { ChromeHelpMenuLink, ChromeNavControl, ChromeNavLink, + ChromeNextHeaderConfig, } from '@kbn/core-chrome-browser'; import type { ApplicationStart } from '@kbn/core-application-browser'; import type { MountPoint } from '@kbn/core-mount-utils-browser'; @@ -279,3 +280,14 @@ export function useHasAppMenu(): boolean { const hasAppMenuConfig = useHasAppMenuConfig(); return hasLegacyActionMenu || hasAppMenuConfig; } + +/** + * Returns the current Chrome-Next header configuration set via + * `chrome.next.header.set()`, or `undefined` if not set. + * Used by Chrome-Next top bar components. + */ +export function useNextHeader(): ChromeNextHeaderConfig | undefined { + const chrome = useChromeService(); + const config$ = useMemo(() => chrome.next.header.get$(), [chrome]); + return useObservable(config$, undefined); +} diff --git a/src/core/packages/chrome/browser-internal-types/index.ts b/src/core/packages/chrome/browser-internal-types/index.ts index a4eed585080e5..fd688bcc0e8f9 100644 --- a/src/core/packages/chrome/browser-internal-types/index.ts +++ b/src/core/packages/chrome/browser-internal-types/index.ts @@ -15,7 +15,7 @@ import type { ChromeBadge, ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension, - ChromeProjectHeaderConfig, + ChromeNextHeaderConfig, ChromeProjectNavigationNode, ChromeSetProjectBreadcrumbsParams, ChromeUserBanner, @@ -106,13 +106,13 @@ export interface InternalChromeStart extends ChromeStart { ): void; }; - /** @internal Extends the public projectHeader with `get$` for Chrome layout components. */ - projectHeader: ChromeStart['projectHeader'] & { - /** - * Get an observable of the current project header configuration. - * Used by Chrome-Next layout components to render the title area, - * global actions, and app menu. - */ - get$(): Observable; + /** @internal Extends public `next` with `get$` for Chrome layout components. */ + next: ChromeStart['next'] & { + header: ChromeStart['next']['header'] & { + get$(): Observable; + }; + aiButton: ChromeStart['next']['aiButton'] & { + get$(): Observable; + }; }; } diff --git a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx index 2840ad4ae2d3d..b9e797727f314 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx @@ -18,13 +18,13 @@ import type { ChromeState } from './state/chrome_state'; import type { NavControlsService } from './services/nav_controls'; import type { NavLinksService } from './services/nav_links'; import type { ProjectNavigationService } from './services/project_navigation'; -import type { ProjectHeaderService } from './services/project_header'; +import type { NextHeaderService } from './services/next_header'; import type { DocTitleService } from './services/doc_title'; type NavControlsStart = ReturnType; type NavLinksStart = ReturnType; type ProjectNavigationStart = ReturnType; -type ProjectHeaderStart = ReturnType; +type NextHeaderStart = ReturnType; type DocTitleStart = ReturnType; type RecentlyAccessedStart = ReturnType; @@ -36,7 +36,7 @@ export interface ChromeApiDeps { recentlyAccessed: RecentlyAccessedStart; docTitle: DocTitleStart; projectNavigation: ProjectNavigationStart; - projectHeader: ProjectHeaderStart; + nextHeader: NextHeaderStart; }; sidebar: SidebarStart; } @@ -170,10 +170,15 @@ export function createChromeApi({ state, services, sidebar }: ChromeApiDeps): In getActiveSolutionNavId: () => projectNavigation.getActiveSolutionNavId(), project, - // Project Header - projectHeader: { - get$: services.projectHeader.get$, - set: services.projectHeader.set, + next: { + header: { + get$: services.nextHeader.get$, + set: services.nextHeader.set, + }, + aiButton: { + get$: () => state.aiButton.$, + set: state.aiButton.set, + }, }, sidebar, diff --git a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx index 59e64d4ba4225..0086de3da4623 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_service.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_service.tsx @@ -30,7 +30,7 @@ import { DocTitleService } from './services/doc_title'; import { NavControlsService } from './services/nav_controls'; import { NavLinksService } from './services/nav_links'; import { ProjectNavigationService } from './services/project_navigation'; -import { ProjectHeaderService } from './services/project_header'; +import { NextHeaderService } from './services/next_header'; import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; import type { InternalChromeSetup, InternalChromeStart } from './types'; import { createChromeState } from './state'; @@ -76,7 +76,7 @@ export class ChromeService { private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); private readonly projectNavigation: ProjectNavigationService; - private readonly projectHeader: ProjectHeaderService; + private readonly nextHeader: NextHeaderService; private readonly sidebar: SidebarService; private readonly logger: Logger; private readonly isServerless: boolean; @@ -85,7 +85,7 @@ export class ChromeService { this.logger = params.coreContext.logger.get('chrome-browser'); this.isServerless = params.coreContext.env.packageInfo.buildFlavor === 'serverless'; this.projectNavigation = new ProjectNavigationService(this.isServerless); - this.projectHeader = new ProjectHeaderService(); + this.nextHeader = new NextHeaderService(); this.sidebar = new SidebarService({ basePath: params.basePath }); } @@ -167,7 +167,7 @@ export class ChromeService { chromeBreadcrumbs$: state.breadcrumbs.classic.$, }); - const projectHeader = this.projectHeader.start(); + const nextHeaderStart = this.nextHeader.start(); const sidebar = this.sidebar.start(); @@ -177,7 +177,7 @@ export class ChromeService { stop$: this.stop$, state, docTitle, - projectHeader, + nextHeader: nextHeaderStart, }); // 6. Return chrome API @@ -189,7 +189,7 @@ export class ChromeService { recentlyAccessed, docTitle, projectNavigation, - projectHeader, + nextHeader: nextHeaderStart, }, sidebar, }); @@ -201,7 +201,7 @@ export class ChromeService { this.navControls.stop(); this.navLinks.stop(); this.projectNavigation.stop(); - this.projectHeader.stop(); + this.nextHeader.stop(); this.sidebar.stop(); this.stop$.next(); } diff --git a/src/core/packages/chrome/browser-internal/src/services/project_header/index.ts b/src/core/packages/chrome/browser-internal/src/services/next_header/index.ts similarity index 87% rename from src/core/packages/chrome/browser-internal/src/services/project_header/index.ts rename to src/core/packages/chrome/browser-internal/src/services/next_header/index.ts index 3dfb5d216fb89..e4f9347526f4e 100644 --- a/src/core/packages/chrome/browser-internal/src/services/project_header/index.ts +++ b/src/core/packages/chrome/browser-internal/src/services/next_header/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { ProjectHeaderService } from './project_header_service'; +export { NextHeaderService } from './next_header_service'; diff --git a/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts b/src/core/packages/chrome/browser-internal/src/services/next_header/next_header_service.ts similarity index 76% rename from src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts rename to src/core/packages/chrome/browser-internal/src/services/next_header/next_header_service.ts index 5d643a979118f..488502d43c280 100644 --- a/src/core/packages/chrome/browser-internal/src/services/project_header/project_header_service.ts +++ b/src/core/packages/chrome/browser-internal/src/services/next_header/next_header_service.ts @@ -8,17 +8,17 @@ */ import { ReplaySubject } from 'rxjs'; -import type { ChromeProjectHeaderConfig } from '@kbn/core-chrome-browser'; +import type { ChromeNextHeaderConfig } from '@kbn/core-chrome-browser'; import { createState } from '../../state/state_helpers'; -export class ProjectHeaderService { +export class NextHeaderService { private readonly stop$ = new ReplaySubject(1); - private readonly config = createState(undefined); + private readonly config = createState(undefined); public start() { return { get$: () => this.config.$, - set: (value?: ChromeProjectHeaderConfig) => this.config.set(value), + set: (value?: ChromeNextHeaderConfig) => this.config.set(value), /** @internal Reset to initial state (e.g. on app change). */ reset: () => this.config.set(undefined), }; diff --git a/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts b/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts index 59dd7ba151e80..f9b72f7ed230d 100644 --- a/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts +++ b/src/core/packages/chrome/browser-internal/src/side_effects/app_change_handler.ts @@ -11,16 +11,16 @@ import type { Observable } from 'rxjs'; import { takeUntil } from 'rxjs'; import type { ChromeDocTitle } from '@kbn/core-chrome-browser'; import type { ChromeState } from '../state/chrome_state'; -import type { ProjectHeaderService } from '../services/project_header'; +import type { NextHeaderService } from '../services/next_header'; -type ProjectHeaderStart = ReturnType; +type NextHeaderStart = ReturnType; export interface AppChangeHandlerDeps { currentAppId$: Observable; stop$: Observable; state: ChromeState; docTitle: ChromeDocTitle; - projectHeader: ProjectHeaderStart; + nextHeader: NextHeaderStart; } /** @@ -32,7 +32,7 @@ export function setupAppChangeHandler({ stop$, state, docTitle, - projectHeader, + nextHeader, }: AppChangeHandlerDeps): void { currentAppId$.pipe(takeUntil(stop$)).subscribe(() => { // Reset UI elements @@ -49,7 +49,7 @@ export function setupAppChangeHandler({ // Reset document title docTitle.reset(); - // Reset Chrome-Next project header - projectHeader.reset(); + // Reset Chrome-Next header config (AI button is global and not cleared here) + nextHeader.reset(); }); } diff --git a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts index 918e0e1897a6c..4084d3645818b 100644 --- a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts +++ b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts @@ -64,6 +64,7 @@ export interface ChromeState { globalFooter: State; customNavLink: State; appMenu: State; + aiButton: State; /** Help system */ help: { @@ -107,6 +108,7 @@ export function createChromeState({ application, docLinks }: ChromeStateDeps): C // UI Elements (not reset on app change) const globalFooter = createState(null); + const aiButton = createState(undefined); const customNavLink = createState(undefined); // Help System @@ -130,6 +132,7 @@ export function createChromeState({ application, docLinks }: ChromeStateDeps): C }, headerBanner, globalFooter, + aiButton, customNavLink, appMenu, help: { diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index bdfc1abf92c9e..069bd2f5018f7 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -98,9 +98,15 @@ const createStartContractMock = () => { getNavigation$: jest.fn().mockReturnValue(new BehaviorSubject({} as any)), getProjectHome$: jest.fn().mockReturnValue(of('/')), }), - projectHeader: lazyObject({ - get$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), - set: jest.fn(), + next: lazyObject({ + header: lazyObject({ + get$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), + set: jest.fn(), + }), + aiButton: lazyObject({ + get$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), + set: jest.fn(), + }), }), setGlobalFooter: jest.fn(), getGlobalFooter$: jest.fn().mockReturnValue(new BehaviorSubject(null)), diff --git a/src/core/packages/chrome/browser/index.ts b/src/core/packages/chrome/browser/index.ts index 9c66bf3926540..86a28a77de342 100644 --- a/src/core/packages/chrome/browser/index.ts +++ b/src/core/packages/chrome/browser/index.ts @@ -55,9 +55,9 @@ export type { SidebarAppDefinition, SidebarSetup, SidebarStart, - ChromeProjectHeaderConfig, - ChromeProjectHeaderMetadataItem, - ChromeProjectHeaderGlobalActions, - ChromeProjectHeaderTab, - ChromeProjectHeaderCallout, + ChromeNextHeaderConfig, + ChromeNextHeaderMetadataItem, + ChromeNextHeaderGlobalActions, + ChromeNextHeaderTab, + ChromeNextHeaderCallout, } from './src'; diff --git a/src/core/packages/chrome/browser/src/contracts.ts b/src/core/packages/chrome/browser/src/contracts.ts index 4a3cb6c3eba41..6630fc272b756 100644 --- a/src/core/packages/chrome/browser/src/contracts.ts +++ b/src/core/packages/chrome/browser/src/contracts.ts @@ -10,7 +10,7 @@ import type { ReactNode } from 'react'; import type { Observable } from 'rxjs'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; -import type { ChromeProjectHeaderConfig } from './project_header'; +import type { ChromeNextHeaderConfig } from './next_header'; import type { ChromeNavLink, ChromeNavLinks } from './nav_links'; import type { ChromeRecentlyAccessed } from './recently_accessed'; import type { ChromeDocTitle } from './doc_title'; @@ -324,19 +324,28 @@ export interface ChromeStart { getActiveSolutionNavId(): SolutionId | null; /** - * APIs for the Chrome-controlled project header (Chrome-Next). - * Provides a unified configuration surface for page identity, global - * actions, and app menu — all rendered by Chrome rather than by individual apps. - */ - projectHeader: { - /** - * Set the project header configuration for the current page. - * Chrome renders the title, metadata, global actions, and app menu. - * - * Pass `undefined` to clear (e.g. on unmount or route change). - * Automatically cleared on app change. - */ - set(config?: ChromeProjectHeaderConfig): void; + * Chrome-Next APIs: header configuration, AI button slot, and future slots. + */ + next: { + header: { + /** + * Set the Chrome-Next header configuration for the current page. + * Chrome renders the title, metadata, global actions, and app menu. + * + * Pass `undefined` to clear (e.g. on unmount or route change). + * Automatically cleared on app change. + */ + set(config?: ChromeNextHeaderConfig): void; + }; + aiButton: { + /** + * Set the AI button component for the Chrome-Next header. + * The plugin owns the full component (rendering, state, tooltips, shortcuts). + * Chrome renders it in a fixed slot at the far right of the header. + * Pass `undefined` to remove. Global — persists across app changes. + */ + set(node?: ReactNode): void; + }; }; /** diff --git a/src/core/packages/chrome/browser/src/index.ts b/src/core/packages/chrome/browser/src/index.ts index 1ce6b4e67f1be..2725c37168ac1 100644 --- a/src/core/packages/chrome/browser/src/index.ts +++ b/src/core/packages/chrome/browser/src/index.ts @@ -61,9 +61,9 @@ export type { } from './sidebar'; export type { - ChromeProjectHeaderConfig, - ChromeProjectHeaderMetadataItem, - ChromeProjectHeaderGlobalActions, - ChromeProjectHeaderTab, - ChromeProjectHeaderCallout, -} from './project_header'; + ChromeNextHeaderConfig, + ChromeNextHeaderMetadataItem, + ChromeNextHeaderGlobalActions, + ChromeNextHeaderTab, + ChromeNextHeaderCallout, +} from './next_header'; diff --git a/src/core/packages/chrome/browser/src/project_header.ts b/src/core/packages/chrome/browser/src/next_header.ts similarity index 82% rename from src/core/packages/chrome/browser/src/project_header.ts rename to src/core/packages/chrome/browser/src/next_header.ts index 0d82f550a02a7..73586c96c9a5d 100644 --- a/src/core/packages/chrome/browser/src/project_header.ts +++ b/src/core/packages/chrome/browser/src/next_header.ts @@ -10,11 +10,11 @@ import type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; /** - * Unified configuration for the Chrome-controlled project header. - * Apps provide structured data via `chrome.projectHeader.set(config)`; + * Unified configuration for the Chrome-controlled Chrome-Next header. + * Apps provide structured data via `chrome.next.header.set(config)`; * Chrome renders title area, global actions, and app menu. */ -export interface ChromeProjectHeaderConfig { +export interface ChromeNextHeaderConfig { /** * Page title displayed in the Chrome-controlled header. * Can be the app name (e.g. "Discover") or a viewed object @@ -27,7 +27,7 @@ export interface ChromeProjectHeaderConfig { * E.g. "Managed", "Read-only", creation date, severity. * Limited to a single row; Chrome controls layout. */ - metadata?: ChromeProjectHeaderMetadataItem[]; + metadata?: ChromeNextHeaderMetadataItem[]; /** * Global object actions whose icon, label, and position are fixed by Chrome. @@ -35,19 +35,19 @@ export interface ChromeProjectHeaderConfig { * * Rendering order (fixed by Chrome): edit, share, favorite. */ - globalActions?: ChromeProjectHeaderGlobalActions; + globalActions?: ChromeNextHeaderGlobalActions; /** * Optional tabs rendered as part of the header. * When provided, increases the top bar height dynamically. */ - tabs?: ChromeProjectHeaderTab[]; + tabs?: ChromeNextHeaderTab[]; /** * Optional single callout rendered below the title/tabs area. * Only one callout at a time. Chrome controls styling. */ - callout?: ChromeProjectHeaderCallout; + callout?: ChromeNextHeaderCallout; /** * App menu (toolbar actions). Items, primary action, secondary action. @@ -56,7 +56,7 @@ export interface ChromeProjectHeaderConfig { appMenu?: AppMenuConfigNext; } -export interface ChromeProjectHeaderMetadataItem { +export interface ChromeNextHeaderMetadataItem { label: string; type: 'badge' | 'text'; /** Badge color (EUI badge color). Only used when type is 'badge'. */ @@ -68,7 +68,7 @@ export interface ChromeProjectHeaderMetadataItem { * Global actions whose icon, label, and position are fixed by Chrome. * Apps provide only the behavioral handlers. */ -export interface ChromeProjectHeaderGlobalActions { +export interface ChromeNextHeaderGlobalActions { /** Edit action. Chrome renders a pencil icon next to the title. */ edit?: { onClick: () => void; @@ -84,7 +84,7 @@ export interface ChromeProjectHeaderGlobalActions { }; } -export interface ChromeProjectHeaderTab { +export interface ChromeNextHeaderTab { id: string; label: string; isSelected?: boolean; @@ -93,7 +93,7 @@ export interface ChromeProjectHeaderTab { badge?: number; } -export interface ChromeProjectHeaderCallout { +export interface ChromeNextHeaderCallout { title: string; color: 'primary' | 'warning' | 'danger' | 'success'; text?: string; diff --git a/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx b/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx index 7af8f70fd5e06..f77dc12b81c25 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx @@ -289,6 +289,14 @@ export class AgentBuilderPlugin // right before the user profile order: 1001, }); + + core.chrome.next.aiButton.set( + + ); } return agentBuilderService; From ff03905f9156b7bd03c07d984018453519dcdbec Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 25 Mar 2026 15:06:30 +0100 Subject: [PATCH 06/77] applicationMarginTop: 8 --- .../layout/core-chrome-layout/layouts/grid/grid_layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx index 6bf80f3da59a8..c460774506af2 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx @@ -73,6 +73,7 @@ const layoutConfigs: { applicationTopBarHeight: 0, applicationMarginRight: 8, applicationMarginBottom: 8, + applicationMarginTop: 8, sidebarWidth: 0, footerHeight: 0, navigationWidth: 0, From b6a4d9a3622248f2190a64532a3a9f96310d63e3 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 25 Mar 2026 15:15:08 +0100 Subject: [PATCH 07/77] fix post merge issues --- .../src/project_next/hooks/index.ts | 3 +++ .../project_next/hooks/use_project_header.ts | 24 ------------------- .../hooks/use_project_next_app_menu.ts | 7 +++--- .../src/project_next/hooks/use_title.test.tsx | 6 ++--- .../src/project_next/hooks/use_title.ts | 4 ++-- 5 files changed, 11 insertions(+), 33 deletions(-) delete mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts index e85302f90fc89..4d785ed63cb42 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts @@ -8,3 +8,6 @@ */ export { useAiButton } from './use_ai_button'; +export { useProjectNextAppMenu } from './use_project_next_app_menu'; +export { useReportTopBarHeight } from './use_report_top_bar_height'; +export { useTitle } from './use_title'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts deleted file mode 100644 index 309246fdffaa0..0000000000000 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_header.ts +++ /dev/null @@ -1,24 +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 { useMemo } from 'react'; -import type { ChromeProjectHeaderConfig } from '@kbn/core-chrome-browser'; -import { useObservable } from '@kbn/use-observable'; -import { useChromeService } from '@kbn/core-chrome-browser-context'; - -/** - * Returns the current project header configuration set via - * `chrome.projectHeader.set()`, or `undefined` if not set. - * Used by Chrome-Next top bar components. - */ -export function useProjectHeader(): ChromeProjectHeaderConfig | undefined { - const chrome = useChromeService(); - const config$ = useMemo(() => chrome.projectHeader.get$(), [chrome]); - return useObservable(config$, undefined); -} diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts index def6921eadfdc..1a7815e88af9c 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts @@ -8,15 +8,14 @@ */ import type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; -import { useAppMenu } from '../../shared/chrome_hooks'; -import { useProjectHeader } from './use_project_header'; +import { useAppMenu, useNextHeader } from '../../shared/chrome_hooks'; /** * Returns the app menu config for the Chrome-Next project header. - * Fallback: `config.appMenu` from `projectHeader.set()` -> global `chrome.getAppMenu$()`. + * Fallback: `config.appMenu` from `chrome.next.header.set()` -> global `chrome.getAppMenu$()`. */ export function useProjectNextAppMenu(): AppMenuConfigNext | undefined { - const config = useProjectHeader(); + const config = useNextHeader(); const globalAppMenu = useAppMenu(); return config?.appMenu ?? globalAppMenu; } diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx index b8d4d86140093..5ab83c92ce4a3 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { renderHook, act } from '@testing-library/react'; import type { BehaviorSubject } from 'rxjs'; -import type { ChromeProjectHeaderConfig } from '@kbn/core-chrome-browser'; +import type { ChromeNextHeaderConfig } from '@kbn/core-chrome-browser'; import { createMockChromeComponentsDeps, TestChromeProviders } from '../../test_helpers'; import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; import { useTitle } from './use_title'; @@ -37,12 +37,12 @@ describe('useTitle', () => { expect(result.current).toBe('Unknown'); }); - it('prefers projectHeader config title over app title', () => { + it('prefers chrome.next.header config title over app title', () => { const deps = createMockChromeComponentsDeps(); (deps.application.currentAppTitle$ as BehaviorSubject).next('Dashboards'); const chrome = chromeServiceMock.createStartContract(); - (chrome.projectHeader.get$() as BehaviorSubject).next({ + (chrome.next.header.get$() as BehaviorSubject).next({ title: 'My Dashboard', }); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts index 10749b7b2c376..782a036564491 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts @@ -9,14 +9,14 @@ import { useObservable } from '@kbn/use-observable'; import { useChromeComponentsDeps } from '../../context'; -import { useProjectHeader } from './use_project_header'; +import { useNextHeader } from '../../shared/chrome_hooks'; /** * Returns the display title for the Chrome-Next project header. * Fallback chain: explicit `config.title` -> current app title -> 'Unknown'. */ export function useTitle(): string { - const config = useProjectHeader(); + const config = useNextHeader(); const { application } = useChromeComponentsDeps(); const appTitle = useObservable(application.currentAppTitle$, undefined); return config?.title ?? appTitle ?? 'Unknown'; From 06068df81de32475e4cf377aa19038f2be0e6e48 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 26 Mar 2026 13:21:55 +0100 Subject: [PATCH 08/77] [Chrome Next] Back button, use breadcrumbs for title fallback (#259748) ## Summary - back button, ~use root breadcrumb as href~ use last not current breadcrumbs as link - for title use the last breadcrumb - revert `currentAppTitle`, it isn't useful - layout improvements Screenshot 2026-03-26 at 12 30 50 --- .../src/application_service.tsx | 9 +- .../src/application_service.mock.ts | 2 - .../application/browser/src/contracts.ts | 7 - .../chrome/browser-components/src/context.tsx | 2 +- .../src/project_next/app_menu.tsx | 20 +- .../src/project_next/back_button.tsx | 52 +++++ .../src/project_next/header.tsx | 44 ++-- .../src/project_next/hooks/index.ts | 1 + .../hooks/use_back_button.test.tsx | 199 ++++++++++++++++++ .../src/project_next/hooks/use_back_button.ts | 53 +++++ .../src/project_next/hooks/use_title.test.tsx | 87 ++++++-- .../src/project_next/hooks/use_title.ts | 31 ++- .../src/project_next/title.tsx | 48 +++++ .../project_next/trailing_actions.test.tsx | 47 +++++ .../src/project_next/trailing_actions.tsx | 56 +++++ .../src/shared/breadcrumb_utils.ts | 14 ++ src/core/packages/chrome/browser/index.ts | 1 + src/core/packages/chrome/browser/src/index.ts | 1 + .../chrome/browser/src/next_header.ts | 13 ++ 19 files changed, 603 insertions(+), 84 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/project_next/back_button.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.ts create mode 100644 src/core/packages/chrome/browser-components/src/project_next/title.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx create mode 100644 src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx diff --git a/src/core/packages/application/browser-internal/src/application_service.tsx b/src/core/packages/application/browser-internal/src/application_service.tsx index c477f4ceea0bc..583b9914e50fd 100644 --- a/src/core/packages/application/browser-internal/src/application_service.tsx +++ b/src/core/packages/application/browser-internal/src/application_service.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { flushSync } from 'react-dom'; import { BehaviorSubject, firstValueFrom, type Observable, Subject, type Subscription } from 'rxjs'; -import { map, shareReplay, switchMap, takeUntil, distinctUntilChanged, filter, take } from 'rxjs'; +import { map, shareReplay, takeUntil, distinctUntilChanged, filter, take } from 'rxjs'; import type { History } from 'history'; import { createBrowserHistory } from 'history'; @@ -330,13 +330,6 @@ export class ApplicationService { distinctUntilChanged(), takeUntil(this.stop$) ), - currentAppTitle$: this.currentAppId$.pipe( - filter((appId) => appId !== undefined), - distinctUntilChanged(), - switchMap((appId) => applications$.pipe(map((apps) => apps.get(appId)?.title))), - distinctUntilChanged(), - takeUntil(this.stop$) - ), currentActionMenu$: this.currentActionMenu$.pipe( distinctUntilChanged(), takeUntil(this.stop$) diff --git a/src/core/packages/application/browser-mocks/src/application_service.mock.ts b/src/core/packages/application/browser-mocks/src/application_service.mock.ts index cf0d0e6aec5f4..31951853011e6 100644 --- a/src/core/packages/application/browser-mocks/src/application_service.mock.ts +++ b/src/core/packages/application/browser-mocks/src/application_service.mock.ts @@ -49,7 +49,6 @@ const createStartContractMock = (): jest.Mocked => { return lazyObject({ applications$: new BehaviorSubject>(new Map()), currentAppId$: currentAppId$.asObservable(), - currentAppTitle$: new BehaviorSubject(undefined), currentLocation$: currentLocation$.asObservable(), capabilities: capabilitiesServiceMock.createStartContract().capabilities, navigateToApp: jest.fn(), @@ -93,7 +92,6 @@ const createInternalStartContractMock = ( applications$: new BehaviorSubject>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), - currentAppTitle$: new BehaviorSubject(undefined), currentLocation$: currentLocation$.asObservable(), currentActionMenu$: new BehaviorSubject(undefined), getComponent: jest.fn(), diff --git a/src/core/packages/application/browser/src/contracts.ts b/src/core/packages/application/browser/src/contracts.ts index 827b498ddeab1..caa336c1b37d2 100644 --- a/src/core/packages/application/browser/src/contracts.ts +++ b/src/core/packages/application/browser/src/contracts.ts @@ -152,13 +152,6 @@ export interface ApplicationStart { */ currentAppId$: Observable; - /** - * An observable that emits the title of the currently mounted application. - * Derived from {@link ApplicationStart.currentAppId$ | currentAppId$} and - * {@link ApplicationStart.applications$ | applications$}. - */ - currentAppTitle$: Observable; - /** * An observable that emits the current path#hash and each subsequent update using the global history instance */ diff --git a/src/core/packages/chrome/browser-components/src/context.tsx b/src/core/packages/chrome/browser-components/src/context.tsx index 1f77579dac6b4..c9c584e400601 100644 --- a/src/core/packages/chrome/browser-components/src/context.tsx +++ b/src/core/packages/chrome/browser-components/src/context.tsx @@ -17,7 +17,7 @@ import type { CustomBrandingStart } from '@kbn/core-custom-branding-browser'; export interface ChromeComponentsDeps { application: Pick< InternalApplicationStart, - 'navigateToUrl' | 'currentAppId$' | 'currentAppTitle$' | 'currentActionMenu$' + 'navigateToUrl' | 'currentAppId$' | 'currentActionMenu$' >; http: Pick; docLinks: DocLinksStart; diff --git a/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx b/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx index 5bea0717f4242..7069b337cf65f 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { css } from '@emotion/react'; import React, { lazy, Suspense } from 'react'; import { useHasLegacyActionMenu } from '../shared/chrome_hooks'; import { HeaderActionMenu } from '../shared/header_action_menu'; @@ -18,11 +17,6 @@ const AppMenuComponent = lazy(async () => { return { default: Component }; }); -const styles = css` - margin-left: auto; - flex-shrink: 0; -`; - /** * Renders the app menu for the Chrome-Next project header. * Fallback chain: merged AppMenuConfig -> legacy HeaderActionMenu -> nothing. @@ -33,20 +27,14 @@ export const ProjectNextAppMenu = React.memo(() => { if (appMenuConfig) { return ( -
- - - -
+ + + ); } if (hasLegacyActionMenu) { - return ( -
- -
- ); + return ; } return null; diff --git a/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx b/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx new file mode 100644 index 0000000000000..5ba22942dae8e --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { useBackButton } from './hooks'; + +/** + * Back control for Chrome-Next project header: uses explicit `chrome.next.header` `back` when + * set; otherwise the last non-last project breadcrumb with an `href` (≥2 crumbs; scanning + * right to left). Renders nothing when neither applies. + */ +export const ProjectNextBackButton = React.memo(() => { + const back = useBackButton(); + + const ariaLabel = useMemo(() => { + if (!back) { + return ''; + } + if (back.backDestinationLabel) { + return i18n.translate('core.ui.chrome.projectNextHeader.backButtonAriaLabelWithDestination', { + defaultMessage: 'Back to {destination}', + values: { destination: back.backDestinationLabel }, + }); + } + return i18n.translate('core.ui.chrome.projectNextHeader.backButtonAriaLabel', { + defaultMessage: 'Back', + }); + }, [back]); + + if (!back) { + return null; + } + + return ( + + ); +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/header.tsx b/src/core/packages/chrome/browser-components/src/project_next/header.tsx index 5c1bc404b6788..74643fb35dee4 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/header.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/header.tsx @@ -7,46 +7,50 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useEuiTheme, type UseEuiTheme } from '@elastic/eui'; +import { useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; -import { useReportTopBarHeight, useTitle } from './hooks'; -import { ProjectNextAppMenu } from './app_menu'; +import { ProjectNextBackButton } from './back_button'; +import { useReportTopBarHeight } from './hooks'; +import { ProjectNextTitle } from './title'; +import { ProjectNextTrailingActions } from './trailing_actions'; -const useStyles = (euiTheme: UseEuiTheme['euiTheme']) => - useMemo(() => { +const useHeaderStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { const root = css` display: flex; - align-items: center; + flex-direction: column; + min-width: 0; padding: ${euiTheme.size.s}; background: ${euiTheme.colors.backgroundBasePlain}; border-bottom: ${euiTheme.border.thin}; margin-bottom: -${euiTheme.border.width.thin}; `; - const title = css` - font-size: ${euiTheme.size.base}; - font-weight: ${euiTheme.font.weight.semiBold}; - line-height: ${euiTheme.size.l}; - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + const primaryRow = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.xs}; + min-width: 0; `; - return { root, title }; + return { root, primaryRow }; }, [euiTheme]); +}; export const ProjectNextHeader = React.memo(() => { - const title = useTitle(); - const { euiTheme } = useEuiTheme(); - const styles = useStyles(euiTheme); + const styles = useHeaderStyles(); const heightRef = useReportTopBarHeight(); return (
-

{title}

- +
+ + + +
); }); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts index 4d785ed63cb42..6aa997cbcd06d 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts @@ -9,5 +9,6 @@ export { useAiButton } from './use_ai_button'; export { useProjectNextAppMenu } from './use_project_next_app_menu'; +export { useBackButton } from './use_back_button'; export { useReportTopBarHeight } from './use_report_top_bar_height'; export { useTitle } from './use_title'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx new file mode 100644 index 0000000000000..cea15b0a0bdeb --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx @@ -0,0 +1,199 @@ +/* + * 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 React from 'react'; +import { renderHook } from '@testing-library/react'; +import type { BehaviorSubject } from 'rxjs'; +import type { ChromeBreadcrumb, ChromeNextHeaderConfig } from '@kbn/core-chrome-browser'; +import { createMockChromeComponentsDeps, TestChromeProviders } from '../../test_helpers'; +import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; +import { useBackButton } from './use_back_button'; + +describe('useBackButton', () => { + it('prefers explicit chrome.next.header back over breadcrumbs', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([ + { text: 'Root', href: '/app/r' }, + { text: 'Section', href: '/app/section' }, + { text: 'Current' }, + ]); + + (chrome.next.header.get$() as BehaviorSubject).next({ + title: 'T', + back: { href: '/app/explicit', label: 'Explicit' }, + }); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + backHref: '/app/explicit', + backDestinationLabel: 'Explicit', + }); + }); + + it('uses explicit back href without label when label is omitted', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Root', href: '/app/r' }, { text: 'Leaf' }]); + + (chrome.next.header.get$() as BehaviorSubject).next({ + title: 'T', + back: { href: '/app/only-href' }, + }); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + backHref: '/app/only-href', + backDestinationLabel: undefined, + }); + }); + + it('uses explicit back when there is only one breadcrumb', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Only', href: '/app/one' }]); + + (chrome.next.header.get$() as BehaviorSubject).next({ + title: 'T', + back: { href: '/app/back' }, + }); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + backHref: '/app/back', + backDestinationLabel: undefined, + }); + }); + + it('returns undefined when there are fewer than two breadcrumbs', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Only', href: '/app/one' }]); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBeUndefined(); + }); + + it('uses the only non-last crumb with an href when there are two crumbs', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Root', href: '/app/r' }, { text: 'Leaf' }]); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + backHref: '/app/r', + backDestinationLabel: 'Root', + }); + }); + + it('uses the last non-last crumb with an href when several preceding crumbs have hrefs', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([ + { text: 'Root', href: '/app/r' }, + { text: 'Section', href: '/app/section' }, + { text: 'Current' }, + ]); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + backHref: '/app/section', + backDestinationLabel: 'Section', + }); + }); + + it('uses the last non-last crumb with an href when the root has no href', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([ + { text: 'Root' }, + { text: 'Section', href: '/app/section' }, + { text: 'Current' }, + ]); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + backHref: '/app/section', + backDestinationLabel: 'Section', + }); + }); + + it('returns undefined when no non-last crumb has an href', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Root' }, { text: 'Leaf' }]); + + const { result } = renderHook(() => useBackButton(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBeUndefined(); + }); +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.ts new file mode 100644 index 0000000000000..20719383c6028 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.ts @@ -0,0 +1,53 @@ +/* + * 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 { useMemo } from 'react'; +import { useNextHeader, useProjectBreadcrumbs } from '../../shared/chrome_hooks'; +import { getBreadcrumbPlainText } from '../../shared/breadcrumb_utils'; + +export interface BackNavigation { + backHref: string; + /** Plain-text title of the destination crumb (for `aria-label` on the back control). */ + backDestinationLabel?: string; +} + +/** + * Resolution: explicit `chrome.next.header` `back.href` (and optional `back.label`) → else the + * last non-last project breadcrumb with a truthy `href` (scanning right to left). Returns + * `undefined` if neither applies. + */ +export function useBackButton(): BackNavigation | undefined { + const config = useNextHeader(); + const breadcrumbs = useProjectBreadcrumbs(); + + return useMemo(() => { + const explicitHref = config?.back?.href?.trim(); + if (explicitHref) { + return { + backHref: explicitHref, + backDestinationLabel: config?.back?.label, + }; + } + + if (breadcrumbs.length < 2) { + return undefined; + } + for (let i = breadcrumbs.length - 2; i >= 0; i--) { + const crumb = breadcrumbs[i]; + const href = crumb.href; + if (href) { + return { + backHref: href, + backDestinationLabel: getBreadcrumbPlainText(crumb), + }; + } + } + return undefined; + }, [breadcrumbs, config]); +} diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx index 5ab83c92ce4a3..800c2c5f48fa8 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx @@ -8,44 +8,69 @@ */ import React from 'react'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import type { BehaviorSubject } from 'rxjs'; -import type { ChromeNextHeaderConfig } from '@kbn/core-chrome-browser'; +import type { ChromeBreadcrumb, ChromeNextHeaderConfig } from '@kbn/core-chrome-browser'; import { createMockChromeComponentsDeps, TestChromeProviders } from '../../test_helpers'; import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; import { useTitle } from './use_title'; describe('useTitle', () => { - it('returns the app title from currentAppTitle$', () => { + it('returns undefined when there is no config title and project breadcrumbs are empty', () => { const deps = createMockChromeComponentsDeps(); - (deps.application.currentAppTitle$ as BehaviorSubject).next('Discover'); const { result } = renderHook(() => useTitle(), { wrapper: ({ children }) => {children}, }); - expect(result.current).toBe('Discover'); + expect(result.current).toBeUndefined(); }); - it('returns "Unknown" when no app title is available', () => { + it('prefers chrome.next.header config title over breadcrumbs', () => { const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'From breadcrumbs' }]); + + (chrome.next.header.get$() as BehaviorSubject).next({ + title: 'My Dashboard', + }); + const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => ( + + {children} + + ), }); - expect(result.current).toBe('Unknown'); + expect(result.current).toBe('My Dashboard'); }); - it('prefers chrome.next.header config title over app title', () => { + it('uses a single project breadcrumb string text as title', () => { const deps = createMockChromeComponentsDeps(); - (deps.application.currentAppTitle$ as BehaviorSubject).next('Dashboards'); - const chrome = chromeServiceMock.createStartContract(); - (chrome.next.header.get$() as BehaviorSubject).next({ - title: 'My Dashboard', + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Section' }]); + + const { result } = renderHook(() => useTitle(), { + wrapper: ({ children }) => ( + + {children} + + ), }); + expect(result.current).toBe('Section'); + }); + + it('uses the last project breadcrumb text when multiple crumbs', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Root', href: '/app/r' }, { text: 'Leaf' }]); + const { result } = renderHook(() => useTitle(), { wrapper: ({ children }) => ( @@ -54,21 +79,41 @@ describe('useTitle', () => { ), }); - expect(result.current).toBe('My Dashboard'); + expect(result.current).toBe('Leaf'); }); - it('updates when currentAppTitle$ emits a new value', () => { + it('prefers string text over aria-label when both are present on the chosen crumb', () => { const deps = createMockChromeComponentsDeps(); - const title$ = deps.application.currentAppTitle$ as BehaviorSubject; - title$.next('Dashboard'); + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: 'Visible', 'aria-label': 'Aria title' }]); const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => {children}, + wrapper: ({ children }) => ( + + {children} + + ), }); - expect(result.current).toBe('Dashboard'); + expect(result.current).toBe('Visible'); + }); + + it('returns undefined when breadcrumb text is not a plain string', () => { + const deps = createMockChromeComponentsDeps(); + + const chrome = chromeServiceMock.createStartContract(); + const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; + breadcrumbs$.next([{ text: Rich }]); + + const { result } = renderHook(() => useTitle(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); - act(() => title$.next('Discover')); - expect(result.current).toBe('Discover'); + expect(result.current).toBeUndefined(); }); }); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts index 782a036564491..c2f084c5decc1 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts @@ -7,17 +7,30 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useObservable } from '@kbn/use-observable'; -import { useChromeComponentsDeps } from '../../context'; -import { useNextHeader } from '../../shared/chrome_hooks'; +import { useNextHeader, useProjectBreadcrumbs } from '../../shared/chrome_hooks'; +import { getBreadcrumbPlainText } from '../../shared/breadcrumb_utils'; /** - * Returns the display title for the Chrome-Next project header. - * Fallback chain: explicit `config.title` -> current app title -> 'Unknown'. + * Returns the display title for the Chrome-Next project header, or `undefined` when none. + * Resolution: explicit `config.title` -> project breadcrumb text (last crumb) -> `undefined`. */ -export function useTitle(): string { +export function useTitle(): string | undefined { const config = useNextHeader(); - const { application } = useChromeComponentsDeps(); - const appTitle = useObservable(application.currentAppTitle$, undefined); - return config?.title ?? appTitle ?? 'Unknown'; + const breadcrumbs = useProjectBreadcrumbs(); + + if (config?.title) { + return config.title; + } + + if (breadcrumbs.length === 0) { + return undefined; + } + + const crumbForTitle = breadcrumbs[breadcrumbs.length - 1]; + const plain = getBreadcrumbPlainText(crumbForTitle); + if (plain) { + return plain; + } + + return undefined; } diff --git a/src/core/packages/chrome/browser-components/src/project_next/title.tsx b/src/core/packages/chrome/browser-components/src/project_next/title.tsx new file mode 100644 index 0000000000000..317172bebfba3 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/title.tsx @@ -0,0 +1,48 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { useTitle } from './hooks'; + +const useTitleStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { + const title = css` + flex: 1; + min-width: 0; + font-size: ${euiTheme.size.base}; + font-weight: ${euiTheme.font.weight.semiBold}; + line-height: ${euiTheme.size.l}; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + + return { title }; + }, [euiTheme]); +}; + +/** + * Title region for the Chrome-Next project header: renders nothing when + * `useTitle()` has no string; otherwise an `h1` with Emotion styles. + */ +export const ProjectNextTitle = React.memo(() => { + const title = useTitle(); + const styles = useTitleStyles(); + + if (!title) { + return null; + } + + return

{title}

; +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx new file mode 100644 index 0000000000000..f581b5116125a --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { BehaviorSubject } from 'rxjs'; +import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; +import { createMockChromeComponentsDeps, TestChromeProviders } from '../test_helpers'; +import { ProjectNextTrailingActions } from './trailing_actions'; + +describe('ProjectNextTrailingActions', () => { + it('renders nothing when no app menu, legacy action menu, or AI button', () => { + const deps = createMockChromeComponentsDeps(); + + const { container } = render( + + + + ); + + expect( + container.querySelector('[data-test-subj="chromeProjectNextHeaderTrailing"]') + ).toBeNull(); + }); + + it('renders the trailing region when the AI button slot is set', () => { + const deps = createMockChromeComponentsDeps(); + const chrome = chromeServiceMock.createStartContract(); + const aiButton$ = chrome.next.aiButton.get$() as BehaviorSubject; + aiButton$.next(AI); + + render( + + + + ); + + expect(screen.getByTestId('chromeProjectNextHeaderTrailing')).toBeInTheDocument(); + expect(screen.getByTestId('aiButtonTest')).toBeInTheDocument(); + }); +}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx new file mode 100644 index 0000000000000..b285a5d378ac6 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx @@ -0,0 +1,56 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { useHasLegacyActionMenu } from '../shared/chrome_hooks'; +import { AiButtonSlot } from './ai_button_slot'; +import { ProjectNextAppMenu } from './app_menu'; +import { useAiButton, useProjectNextAppMenu } from './hooks'; + +const useTrailingStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { + const root = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + flex-shrink: 0; + margin-left: auto; + `; + + return { root }; + }, [euiTheme]); +}; + +/** + * Trailing region of the Chrome-Next project header (app menu, AI button, future global actions). + * Renders nothing when no trailing content is available. + */ +export const ProjectNextTrailingActions = React.memo(() => { + const appMenuConfig = useProjectNextAppMenu(); + const hasLegacyActionMenu = useHasLegacyActionMenu(); + const aiButton = useAiButton(); + const styles = useTrailingStyles(); + + const hasTrailingContent = !!appMenuConfig || hasLegacyActionMenu || !!aiButton; + + if (!hasTrailingContent) { + return null; + } + + return ( +
+ + +
+ ); +}); diff --git a/src/core/packages/chrome/browser-components/src/shared/breadcrumb_utils.ts b/src/core/packages/chrome/browser-components/src/shared/breadcrumb_utils.ts index 8baadb4d402ea..cccfff6bd869f 100644 --- a/src/core/packages/chrome/browser-components/src/shared/breadcrumb_utils.ts +++ b/src/core/packages/chrome/browser-components/src/shared/breadcrumb_utils.ts @@ -10,6 +10,20 @@ import classNames from 'classnames'; import type { ChromeBreadcrumb } from '@kbn/core-chrome-browser'; +/** + * Returns a plain string for breadcrumb display: prefers `text`, then `aria-label` when strings. + * Returns `undefined` for React node `text`. + */ +export function getBreadcrumbPlainText(crumb: ChromeBreadcrumb): string | undefined { + if (typeof crumb.text === 'string') { + return crumb.text; + } + if (typeof crumb['aria-label'] === 'string') { + return crumb['aria-label']; + } + return undefined; +} + /** Maps raw ChromeBreadcrumb[] to EUI-compatible breadcrumb props */ export function prepareBreadcrumbs(breadcrumbs: ChromeBreadcrumb[]) { const crumbs = breadcrumbs.length === 0 ? [{ text: 'Kibana' } as ChromeBreadcrumb] : breadcrumbs; diff --git a/src/core/packages/chrome/browser/index.ts b/src/core/packages/chrome/browser/index.ts index 86a28a77de342..5d95ff72b282b 100644 --- a/src/core/packages/chrome/browser/index.ts +++ b/src/core/packages/chrome/browser/index.ts @@ -55,6 +55,7 @@ export type { SidebarAppDefinition, SidebarSetup, SidebarStart, + ChromeNextHeaderBack, ChromeNextHeaderConfig, ChromeNextHeaderMetadataItem, ChromeNextHeaderGlobalActions, diff --git a/src/core/packages/chrome/browser/src/index.ts b/src/core/packages/chrome/browser/src/index.ts index 2725c37168ac1..1c13769e1223f 100644 --- a/src/core/packages/chrome/browser/src/index.ts +++ b/src/core/packages/chrome/browser/src/index.ts @@ -61,6 +61,7 @@ export type { } from './sidebar'; export type { + ChromeNextHeaderBack, ChromeNextHeaderConfig, ChromeNextHeaderMetadataItem, ChromeNextHeaderGlobalActions, diff --git a/src/core/packages/chrome/browser/src/next_header.ts b/src/core/packages/chrome/browser/src/next_header.ts index 73586c96c9a5d..f2bdf88a5afb8 100644 --- a/src/core/packages/chrome/browser/src/next_header.ts +++ b/src/core/packages/chrome/browser/src/next_header.ts @@ -54,6 +54,19 @@ export interface ChromeNextHeaderConfig { * TODO: Consider strict type independent from `@kbn/core-chrome-app-menu-components` */ appMenu?: AppMenuConfigNext; + + /** + * Optional explicit back navigation for the Chrome-Next header back control. + * When `href` is set, overrides breadcrumb-derived back destination. + */ + back?: ChromeNextHeaderBack; +} + +/** Explicit back target for {@link ChromeNextHeaderConfig.back}. */ +export interface ChromeNextHeaderBack { + href: string; + /** Destination name for accessibility (e.g. "Back to {label}"). */ + label?: string; } export interface ChromeNextHeaderMetadataItem { From 6c7a091994c6d6bf2150ab53304c2001ec1a90a7 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Fri, 27 Mar 2026 11:42:04 +0100 Subject: [PATCH 09/77] [Chrome Next] Remove AppMenuConfigNext (#259946) ## Summary This PR removes `AppMenuConfigNext` as we'll be modifying `AppMenuConfig` and going straight to main with it (https://github.com/elastic/kibana/pull/259949) --- .../chrome/app-menu/core-chrome-app-menu-components/index.ts | 1 - .../app-menu/core-chrome-app-menu-components/src/index.ts | 1 - .../app-menu/core-chrome-app-menu-components/src/types.ts | 2 -- .../src/project_next/hooks/use_project_next_app_menu.ts | 4 ++-- src/core/packages/chrome/browser/src/next_header.ts | 5 ++--- 5 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts index 476d5c43f0f88..9dfc6c78f738b 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/index.ts @@ -23,7 +23,6 @@ export type { AppMenuPrimaryActionItem, AppMenuPopoverItem, AppMenuSplitButtonProps, - AppMenuConfigNext, } from './src'; export { diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts index 84b253be9a378..d738b52f43576 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/index.ts @@ -23,7 +23,6 @@ export type { AppMenuPrimaryActionItem, AppMenuPopoverItem, AppMenuSplitButtonProps, - AppMenuConfigNext, } from './types'; export { diff --git a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts index cc294f62f0030..32ec560652f65 100644 --- a/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts +++ b/src/core/packages/chrome/app-menu/core-chrome-app-menu-components/src/types.ts @@ -288,5 +288,3 @@ export interface AppMenuConfig { */ secondaryActionItem?: AppMenuSecondaryActionItem; } - -export type AppMenuConfigNext = AppMenuConfig; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts index 1a7815e88af9c..33c08b3d5c4dc 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts @@ -7,14 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; import { useAppMenu, useNextHeader } from '../../shared/chrome_hooks'; /** * Returns the app menu config for the Chrome-Next project header. * Fallback: `config.appMenu` from `chrome.next.header.set()` -> global `chrome.getAppMenu$()`. */ -export function useProjectNextAppMenu(): AppMenuConfigNext | undefined { +export function useProjectNextAppMenu(): AppMenuConfig | undefined { const config = useNextHeader(); const globalAppMenu = useAppMenu(); return config?.appMenu ?? globalAppMenu; diff --git a/src/core/packages/chrome/browser/src/next_header.ts b/src/core/packages/chrome/browser/src/next_header.ts index f2bdf88a5afb8..df79ca72718fb 100644 --- a/src/core/packages/chrome/browser/src/next_header.ts +++ b/src/core/packages/chrome/browser/src/next_header.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { AppMenuConfigNext } from '@kbn/core-chrome-app-menu-components'; +import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; /** * Unified configuration for the Chrome-controlled Chrome-Next header. @@ -51,9 +51,8 @@ export interface ChromeNextHeaderConfig { /** * App menu (toolbar actions). Items, primary action, secondary action. - * TODO: Consider strict type independent from `@kbn/core-chrome-app-menu-components` */ - appMenu?: AppMenuConfigNext; + appMenu?: AppMenuConfig; /** * Optional explicit back navigation for the Chrome-Next header back control. From 07be78dc419095e85656e9fbeab9f681496ae6ab Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 27 Mar 2026 12:30:45 +0100 Subject: [PATCH 10/77] [FeatureBranch/DoNotReview][Chrome Next] Global Actions Stub (#259824) ## Summary Screenshot 2026-03-26 at 17 03 58 --- .../chrome/browser-components/moon.yml | 1 + .../src/project_next/ai_button_slot.tsx | 2 + .../src/project_next/app_menu.tsx | 2 + .../src/project_next/back_button.tsx | 3 + .../src/project_next/global_actions.tsx | 85 ++++++++ .../src/project_next/header.tsx | 48 ++++- .../hooks/use_back_button.test.tsx | 199 ------------------ .../src/project_next/hooks/use_title.test.tsx | 119 ----------- .../src/project_next/title.tsx | 4 +- .../project_next/trailing_actions.test.tsx | 47 ----- .../src/project_next/trailing_actions.tsx | 2 + .../browser-mocks/src/chrome_service.mock.ts | 14 +- src/core/packages/chrome/browser/index.ts | 3 +- src/core/packages/chrome/browser/src/index.ts | 3 +- .../chrome/browser/src/next_header.ts | 57 +++-- .../plugins/shared/dashboard/moon.extend.yml | 2 + .../plugins/shared/dashboard/moon.yml | 2 + .../top_nav/use_dashboard_menu_items.tsx | 13 +- .../internal_dashboard_top_nav.tsx | 38 +++- .../plugins/shared/dashboard/tsconfig.json | 1 + 20 files changed, 241 insertions(+), 404 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/project_next/global_actions.tsx delete mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx delete mode 100644 src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx delete mode 100644 src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx create mode 100644 src/platform/plugins/shared/dashboard/moon.extend.yml diff --git a/src/core/packages/chrome/browser-components/moon.yml b/src/core/packages/chrome/browser-components/moon.yml index a49f5d98a55a9..c90d4a9dd6655 100644 --- a/src/core/packages/chrome/browser-components/moon.yml +++ b/src/core/packages/chrome/browser-components/moon.yml @@ -46,6 +46,7 @@ dependsOn: - '@kbn/shared-ux-label-formatter' - '@kbn/test-jest-helpers' - '@kbn/use-observable' + - '@kbn/core-chrome-layout-components' tags: - shared-browser - package diff --git a/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx b/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx index 489577b63482a..24e5ebfc552d5 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx @@ -23,3 +23,5 @@ export const AiButtonSlot = React.memo(() => { return <>{node}; }); + +AiButtonSlot.displayName = 'AiButtonSlot'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx b/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx index 7069b337cf65f..ed2b371d66e5c 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx @@ -39,3 +39,5 @@ export const ProjectNextAppMenu = React.memo(() => { return null; }); + +ProjectNextAppMenu.displayName = 'ProjectNextAppMenu'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx b/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx index 5ba22942dae8e..bc79b3ca3e3d5 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx @@ -42,6 +42,7 @@ export const ProjectNextBackButton = React.memo(() => { return ( { /> ); }); + +ProjectNextBackButton.displayName = 'ProjectNextBackButton'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/global_actions.tsx b/src/core/packages/chrome/browser-components/src/project_next/global_actions.tsx new file mode 100644 index 0000000000000..d298186962694 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/project_next/global_actions.tsx @@ -0,0 +1,85 @@ +/* + * 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 { EuiButtonIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { useNextHeader } from '../shared/chrome_hooks'; + +const SHARE_ARIA_LABEL = i18n.translate('core.ui.chrome.projectNextHeader.globalShareAriaLabel', { + defaultMessage: 'Share', +}); + +const useGlobalActionsStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { + const root = css` + display: flex; + flex-shrink: 0; + align-items: center; + gap: ${euiTheme.size.xs}; + `; + + const favoriteSlot = css` + display: flex; + flex-shrink: 0; + align-items: center; + `; + + return { root, favoriteSlot }; + }, [euiTheme]); +}; + +/** + * Fixed-order global object actions (editTitle, share, favorite) next to the Chrome-Next title. + * Only renders actions the page opts into via `chrome.next.header.set({ globalActions })`. + * + * Favorite is a `ReactNode` slot so plugins own full behavior (clients, context, React Query). + */ +export const ProjectNextGlobalActions = React.memo(() => { + const config = useNextHeader(); + const styles = useGlobalActionsStyles(); + const globalActions = config?.globalActions; + + if (!globalActions) { + return null; + } + + const { share, favorite } = globalActions; + + if (!share && !favorite) { + return null; + } + + return ( +
+ {/* TODO: editTitle — Chrome-controlled inline title editor; wire onSave from config */} + {share ? ( + + ) : null} + {favorite ? ( +
+ {favorite} +
+ ) : null} +
+ ); +}); + +ProjectNextGlobalActions.displayName = 'ProjectNextGlobalActions'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/header.tsx b/src/core/packages/chrome/browser-components/src/project_next/header.tsx index 74643fb35dee4..f2f20babb0b6b 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/header.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/header.tsx @@ -11,10 +11,14 @@ import { useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import React, { useMemo } from 'react'; import { ProjectNextBackButton } from './back_button'; +import { ProjectNextGlobalActions } from './global_actions'; import { useReportTopBarHeight } from './hooks'; import { ProjectNextTitle } from './title'; import { ProjectNextTrailingActions } from './trailing_actions'; +/** Application top bar height; aligns with project layout `applicationTopBarHeight`. */ +const APPLICATION_TOP_BAR_HEIGHT_PX = 48; + const useHeaderStyles = () => { const { euiTheme } = useEuiTheme(); @@ -23,7 +27,10 @@ const useHeaderStyles = () => { display: flex; flex-direction: column; min-width: 0; - padding: ${euiTheme.size.s}; + height: 100%; + min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; + box-sizing: border-box; + padding: 0 ${euiTheme.size.s}; background: ${euiTheme.colors.backgroundBasePlain}; border-bottom: ${euiTheme.border.thin}; margin-bottom: -${euiTheme.border.width.thin}; @@ -34,9 +41,36 @@ const useHeaderStyles = () => { align-items: center; gap: ${euiTheme.size.xs}; min-width: 0; + flex: 1; + min-height: 0; + `; + + const titleCluster = css` + display: flex; + align-items: center; + flex: 1; + min-width: 0; + `; + + /** + * Keeps the title and global actions grouped on the left; the title truncates with ellipsis + * instead of growing and pushing icons toward the app menu (trailing) region. + */ + const titleGroup = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.xs}; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; `; - return { root, primaryRow }; + const titleClusterSpacer = css` + flex: 1 1 auto; + min-width: 0; + `; + + return { root, primaryRow, titleCluster, titleGroup, titleClusterSpacer }; }, [euiTheme]); }; @@ -48,9 +82,17 @@ export const ProjectNextHeader = React.memo(() => {
- +
+
+ + +
+
+
); }); + +ProjectNextHeader.displayName = 'ProjectNextHeader'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx deleted file mode 100644 index cea15b0a0bdeb..0000000000000 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.test.tsx +++ /dev/null @@ -1,199 +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 React from 'react'; -import { renderHook } from '@testing-library/react'; -import type { BehaviorSubject } from 'rxjs'; -import type { ChromeBreadcrumb, ChromeNextHeaderConfig } from '@kbn/core-chrome-browser'; -import { createMockChromeComponentsDeps, TestChromeProviders } from '../../test_helpers'; -import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; -import { useBackButton } from './use_back_button'; - -describe('useBackButton', () => { - it('prefers explicit chrome.next.header back over breadcrumbs', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([ - { text: 'Root', href: '/app/r' }, - { text: 'Section', href: '/app/section' }, - { text: 'Current' }, - ]); - - (chrome.next.header.get$() as BehaviorSubject).next({ - title: 'T', - back: { href: '/app/explicit', label: 'Explicit' }, - }); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toEqual({ - backHref: '/app/explicit', - backDestinationLabel: 'Explicit', - }); - }); - - it('uses explicit back href without label when label is omitted', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Root', href: '/app/r' }, { text: 'Leaf' }]); - - (chrome.next.header.get$() as BehaviorSubject).next({ - title: 'T', - back: { href: '/app/only-href' }, - }); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toEqual({ - backHref: '/app/only-href', - backDestinationLabel: undefined, - }); - }); - - it('uses explicit back when there is only one breadcrumb', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Only', href: '/app/one' }]); - - (chrome.next.header.get$() as BehaviorSubject).next({ - title: 'T', - back: { href: '/app/back' }, - }); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toEqual({ - backHref: '/app/back', - backDestinationLabel: undefined, - }); - }); - - it('returns undefined when there are fewer than two breadcrumbs', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Only', href: '/app/one' }]); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toBeUndefined(); - }); - - it('uses the only non-last crumb with an href when there are two crumbs', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Root', href: '/app/r' }, { text: 'Leaf' }]); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toEqual({ - backHref: '/app/r', - backDestinationLabel: 'Root', - }); - }); - - it('uses the last non-last crumb with an href when several preceding crumbs have hrefs', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([ - { text: 'Root', href: '/app/r' }, - { text: 'Section', href: '/app/section' }, - { text: 'Current' }, - ]); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toEqual({ - backHref: '/app/section', - backDestinationLabel: 'Section', - }); - }); - - it('uses the last non-last crumb with an href when the root has no href', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([ - { text: 'Root' }, - { text: 'Section', href: '/app/section' }, - { text: 'Current' }, - ]); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toEqual({ - backHref: '/app/section', - backDestinationLabel: 'Section', - }); - }); - - it('returns undefined when no non-last crumb has an href', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Root' }, { text: 'Leaf' }]); - - const { result } = renderHook(() => useBackButton(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toBeUndefined(); - }); -}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx deleted file mode 100644 index 800c2c5f48fa8..0000000000000 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.test.tsx +++ /dev/null @@ -1,119 +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 React from 'react'; -import { renderHook } from '@testing-library/react'; -import type { BehaviorSubject } from 'rxjs'; -import type { ChromeBreadcrumb, ChromeNextHeaderConfig } from '@kbn/core-chrome-browser'; -import { createMockChromeComponentsDeps, TestChromeProviders } from '../../test_helpers'; -import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; -import { useTitle } from './use_title'; - -describe('useTitle', () => { - it('returns undefined when there is no config title and project breadcrumbs are empty', () => { - const deps = createMockChromeComponentsDeps(); - - const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => {children}, - }); - - expect(result.current).toBeUndefined(); - }); - - it('prefers chrome.next.header config title over breadcrumbs', () => { - const deps = createMockChromeComponentsDeps(); - - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'From breadcrumbs' }]); - - (chrome.next.header.get$() as BehaviorSubject).next({ - title: 'My Dashboard', - }); - - const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toBe('My Dashboard'); - }); - - it('uses a single project breadcrumb string text as title', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Section' }]); - - const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toBe('Section'); - }); - - it('uses the last project breadcrumb text when multiple crumbs', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Root', href: '/app/r' }, { text: 'Leaf' }]); - - const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toBe('Leaf'); - }); - - it('prefers string text over aria-label when both are present on the chosen crumb', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: 'Visible', 'aria-label': 'Aria title' }]); - - const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toBe('Visible'); - }); - - it('returns undefined when breadcrumb text is not a plain string', () => { - const deps = createMockChromeComponentsDeps(); - - const chrome = chromeServiceMock.createStartContract(); - const breadcrumbs$ = chrome.project.getBreadcrumbs$() as BehaviorSubject; - breadcrumbs$.next([{ text: Rich }]); - - const { result } = renderHook(() => useTitle(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - expect(result.current).toBeUndefined(); - }); -}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/title.tsx b/src/core/packages/chrome/browser-components/src/project_next/title.tsx index 317172bebfba3..32bf62ce17f50 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/title.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/title.tsx @@ -17,7 +17,7 @@ const useTitleStyles = () => { return useMemo(() => { const title = css` - flex: 1; + flex: 1 1 auto; min-width: 0; font-size: ${euiTheme.size.base}; font-weight: ${euiTheme.font.weight.semiBold}; @@ -46,3 +46,5 @@ export const ProjectNextTitle = React.memo(() => { return

{title}

; }); + +ProjectNextTitle.displayName = 'ProjectNextTitle'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx deleted file mode 100644 index f581b5116125a..0000000000000 --- a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.test.tsx +++ /dev/null @@ -1,47 +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 React from 'react'; -import { render, screen } from '@testing-library/react'; -import type { BehaviorSubject } from 'rxjs'; -import { chromeServiceMock } from '@kbn/core-chrome-browser-mocks'; -import { createMockChromeComponentsDeps, TestChromeProviders } from '../test_helpers'; -import { ProjectNextTrailingActions } from './trailing_actions'; - -describe('ProjectNextTrailingActions', () => { - it('renders nothing when no app menu, legacy action menu, or AI button', () => { - const deps = createMockChromeComponentsDeps(); - - const { container } = render( - - - - ); - - expect( - container.querySelector('[data-test-subj="chromeProjectNextHeaderTrailing"]') - ).toBeNull(); - }); - - it('renders the trailing region when the AI button slot is set', () => { - const deps = createMockChromeComponentsDeps(); - const chrome = chromeServiceMock.createStartContract(); - const aiButton$ = chrome.next.aiButton.get$() as BehaviorSubject; - aiButton$.next(AI); - - render( - - - - ); - - expect(screen.getByTestId('chromeProjectNextHeaderTrailing')).toBeInTheDocument(); - expect(screen.getByTestId('aiButtonTest')).toBeInTheDocument(); - }); -}); diff --git a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx index b285a5d378ac6..0ed5e9974150a 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx @@ -54,3 +54,5 @@ export const ProjectNextTrailingActions = React.memo(() => {
); }); + +ProjectNextTrailingActions.displayName = 'ProjectNextTrailingActions'; diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index 069bd2f5018f7..adfba103e454c 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -9,7 +9,11 @@ import { BehaviorSubject, of } from 'rxjs'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; -import type { ChromeBadge, ChromeBreadcrumb } from '@kbn/core-chrome-browser'; +import type { + ChromeBadge, + ChromeBreadcrumb, + ChromeNextHeaderConfig, +} from '@kbn/core-chrome-browser'; import type { InternalChromeSetup, InternalChromeStart, @@ -24,6 +28,8 @@ const createSetupContractMock = (): DeeplyMockedKeys => { }; const createStartContractMock = () => { + const nextHeaderState$ = new BehaviorSubject(undefined); + const startContract: DeeplyMockedKeys = lazyObject({ withProvider: jest.fn((children) => children), sidebar: lazyObject(sidebarServiceMock.createStartContract()), @@ -100,8 +106,10 @@ const createStartContractMock = () => { }), next: lazyObject({ header: lazyObject({ - get$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), - set: jest.fn(), + get$: jest.fn().mockReturnValue(nextHeaderState$), + set: jest.fn((config?: ChromeNextHeaderConfig) => { + nextHeaderState$.next(config); + }), }), aiButton: lazyObject({ get$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), diff --git a/src/core/packages/chrome/browser/index.ts b/src/core/packages/chrome/browser/index.ts index 5d95ff72b282b..08669b412fd58 100644 --- a/src/core/packages/chrome/browser/index.ts +++ b/src/core/packages/chrome/browser/index.ts @@ -56,8 +56,9 @@ export type { SidebarSetup, SidebarStart, ChromeNextHeaderBack, + ChromeNextHeaderBadge, ChromeNextHeaderConfig, - ChromeNextHeaderMetadataItem, + ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderGlobalActions, ChromeNextHeaderTab, ChromeNextHeaderCallout, diff --git a/src/core/packages/chrome/browser/src/index.ts b/src/core/packages/chrome/browser/src/index.ts index 1c13769e1223f..5057ff2511c9f 100644 --- a/src/core/packages/chrome/browser/src/index.ts +++ b/src/core/packages/chrome/browser/src/index.ts @@ -62,8 +62,9 @@ export type { export type { ChromeNextHeaderBack, + ChromeNextHeaderBadge, ChromeNextHeaderConfig, - ChromeNextHeaderMetadataItem, + ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderGlobalActions, ChromeNextHeaderTab, ChromeNextHeaderCallout, diff --git a/src/core/packages/chrome/browser/src/next_header.ts b/src/core/packages/chrome/browser/src/next_header.ts index df79ca72718fb..e25127cf5bffd 100644 --- a/src/core/packages/chrome/browser/src/next_header.ts +++ b/src/core/packages/chrome/browser/src/next_header.ts @@ -6,7 +6,7 @@ * 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 type { ReactNode } from 'react'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; /** @@ -23,17 +23,22 @@ export interface ChromeNextHeaderConfig { title: string; /** - * Optional metadata badges/text rendered below the title. - * E.g. "Managed", "Read-only", creation date, severity. - * Limited to a single row; Chrome controls layout. + * Badges inline next to the title. Chrome shows 1–2 as-is; for 3+, first badge plus "+N" popover + * for the rest. Max 200px per badge; `filled` is not exposed. TODO: render in `ProjectNextHeader`. + */ + badges?: ChromeNextHeaderBadge[]; + + /** + * Second row below the title (max 3 items, all visible). Text (`EuiText`) or button (`EuiButtonEmpty`). + * TODO: render in `ProjectNextHeader`. */ - metadata?: ChromeNextHeaderMetadataItem[]; + metadata?: ChromeNextHeaderMetadataSlotItem[]; /** - * Global object actions whose icon, label, and position are fixed by Chrome. - * Apps opt-in by providing handlers; they cannot change icon or order. + * Global object actions next to the title. Edit title and share use fixed Chrome icons; + * favorite is an optional app-supplied `ReactNode` (e.g. content-management FavoriteButton). * - * Rendering order (fixed by Chrome): edit, share, favorite. + * Rendering order (fixed by Chrome): editTitle, share, favorite. */ globalActions?: ChromeNextHeaderGlobalActions; @@ -68,32 +73,38 @@ export interface ChromeNextHeaderBack { label?: string; } -export interface ChromeNextHeaderMetadataItem { +export interface ChromeNextHeaderBadge { label: string; - type: 'badge' | 'text'; - /** Badge color (EUI badge color). Only used when type is 'badge'. */ - color?: string; + /** EUI badge color. `filled` is intentionally excluded. */ + color?: 'hollow' | 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'accent'; tooltip?: string; } /** - * Global actions whose icon, label, and position are fixed by Chrome. - * Apps provide only the behavioral handlers. + * A single metadata slot item — either plain text or an interactive button. + * Text renders as `EuiText`; button renders as `EuiButtonEmpty`. + */ +export type ChromeNextHeaderMetadataSlotItem = + | { type: 'text'; label: string } + | { type: 'button'; label: string; onClick: () => void; iconType?: string }; + +/** + * Global actions beside the Chrome-Next title. */ export interface ChromeNextHeaderGlobalActions { - /** Edit action. Chrome renders a pencil icon next to the title. */ - edit?: { - onClick: () => void; + /** + * Inline title edit (Chrome UI TBD). `onSave` runs when the user commits a new title. + */ + editTitle?: { + onSave: (newTitle: string) => void; }; - /** Share action. Chrome renders a share icon. */ + /** Share; Chrome renders the icon. */ share?: { onClick: () => void; }; - /** Favorite/star action. Chrome renders a star icon. */ - favorite?: { - isFavorited: boolean; - onClick: () => void; - }; + /** Favorite control as `ReactNode` (e.g. FavoriteButton) so apps supply providers and clients. */ + /** TODO: should become a structured API */ + favorite?: ReactNode; } export interface ChromeNextHeaderTab { diff --git a/src/platform/plugins/shared/dashboard/moon.extend.yml b/src/platform/plugins/shared/dashboard/moon.extend.yml new file mode 100644 index 0000000000000..9f886f465f7c9 --- /dev/null +++ b/src/platform/plugins/shared/dashboard/moon.extend.yml @@ -0,0 +1,2 @@ +dependsOn: + - '@kbn/core-chrome-browser' diff --git a/src/platform/plugins/shared/dashboard/moon.yml b/src/platform/plugins/shared/dashboard/moon.yml index 1dacb2114a174..4560d45130b1d 100644 --- a/src/platform/plugins/shared/dashboard/moon.yml +++ b/src/platform/plugins/shared/dashboard/moon.yml @@ -114,9 +114,11 @@ dependsOn: - '@kbn/test' - '@kbn/core-chrome-app-menu-components' - '@kbn/core-chrome-app-menu' + - '@kbn/core-chrome-browser' - '@kbn/test-jest-helpers' - '@kbn/scout' - '@kbn/cps-utils' + - '@kbn/core-chrome-browser' tags: - plugin - prod diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 667e44fa5c3c2..99921e30c27f3 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -483,6 +483,13 @@ export const useDashboardMenuItems = ({ isLabsEnabled, ]); + const chromeNextHeaderShareGlobalAction = useMemo(() => { + if (!shareService) { + return undefined; + } + return { onClick: showShare }; + }, [showShare]); + const editModeTopNavConfig = useMemo(() => { const { storeSearchSession } = getDashboardCapabilities(); @@ -525,5 +532,9 @@ export const useDashboardMenuItems = ({ isLabsEnabled, ]); - return { viewModeTopNavConfig, editModeTopNavConfig }; + return { + viewModeTopNavConfig, + editModeTopNavConfig, + chromeNextHeaderShareGlobalAction, + }; }; diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index 5b7afd24ad21a..deaca8cdaaacf 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -31,6 +31,7 @@ import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; import { LazyLabsFlyout, withSuspense } from '@kbn/presentation-util-plugin/public'; import { AppMenu } from '@kbn/core-chrome-app-menu'; +import type { ChromeNextHeaderGlobalActions } from '@kbn/core-chrome-browser'; import { UI_SETTINGS } from '../../common/constants'; import { DASHBOARD_APP_ID } from '../../common/page_bundle_constants'; import type { SaveDashboardReturn } from '../dashboard_api/save_modal/types'; @@ -118,6 +119,11 @@ export function InternalDashboardTopNav({ dashboardInternalApi.unpublishedEsqlVariables$ ); + const chromeNextHeaderFavoriteGlobalAction = useMemo( + () => (lastSavedId ? : undefined), + [lastSavedId] + ); + const hasUnpublishedFilters = useMemo(() => { return !deepEqual(publishedChildFilters ?? [], unpublishedChildFilters ?? []); }, [publishedChildFilters, unpublishedChildFilters]); @@ -298,12 +304,31 @@ export function InternalDashboardTopNav({ [redirectTo] ); - const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({ - isLabsShown, - setIsLabsShown, - maybeRedirect, - showResetChange, - }); + const { viewModeTopNavConfig, editModeTopNavConfig, chromeNextHeaderShareGlobalAction } = + useDashboardMenuItems({ + isLabsShown, + setIsLabsShown, + maybeRedirect, + showResetChange, + }); + + useEffect(() => { + const globalActions: ChromeNextHeaderGlobalActions = {}; + if (chromeNextHeaderShareGlobalAction) { + globalActions.share = chromeNextHeaderShareGlobalAction; + } + if (chromeNextHeaderFavoriteGlobalAction) { + globalActions.favorite = chromeNextHeaderFavoriteGlobalAction; + } + + coreServices.chrome.next.header.set({ + title: title ?? '', + globalActions: Object.keys(globalActions).length > 0 ? globalActions : undefined, + }); + return () => { + coreServices.chrome.next.header.set(undefined); + }; + }, [title, chromeNextHeaderShareGlobalAction, chromeNextHeaderFavoriteGlobalAction]); UseUnmount(() => { dashboardApi.clearOverlays(); @@ -325,6 +350,7 @@ export function InternalDashboardTopNav({ const badgeButton = {badgeText}; return ( setIsPopoverOpen(false)} diff --git a/src/platform/plugins/shared/dashboard/tsconfig.json b/src/platform/plugins/shared/dashboard/tsconfig.json index 895ece8180251..29654473397c8 100644 --- a/src/platform/plugins/shared/dashboard/tsconfig.json +++ b/src/platform/plugins/shared/dashboard/tsconfig.json @@ -112,6 +112,7 @@ "@kbn/test", "@kbn/core-chrome-app-menu-components", "@kbn/core-chrome-app-menu", + "@kbn/core-chrome-browser", "@kbn/test-jest-helpers", "@kbn/scout", "@kbn/cps-utils", From b2f28a51cd7ccc1ff892903463e32f7776bc88f2 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 1 Apr 2026 16:23:02 +0200 Subject: [PATCH 11/77] [ChromeNext] Sidenav tool slots, help pipeline, and global search API stub (#260460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2026-03-31 at 14 37 16 Screenshot 2026-03-31 at 14 56 47 ## Changes - Force logo to be "elastic" with dark text and no rendered lable underneath when behind `chrome-next` - Add header/footer slots into sidenav for injecting chrome functionality - Stub search button into header - Map existing help menu into footer - Rework help menu data generation to map to both the old popover and sidenav section - Expose stub global search api ## Implementation ### Sidenav tool slots (`@kbn/core-chrome-navigation`) The `Navigation` component now accepts an optional `tools` prop (`ToolSlots`) with two zones: - **`headerTools`** — rendered below the logo (e.g. global search button) - **`footerTools`** — rendered above the collapse toggle (e.g. help button) To support this, the old monolithic `Footer` component (which mixed nav links and the collapse button into one element) has been split into three distinct sub-components on `SideNav`: | New component | Role | |---|---| | `SideNav.FooterNav` | Keyboard-navigable ` +
@@ -376,7 +384,6 @@ exports[`Both modes should render the side navigation 1`] = ` aria-pressed="true" class="euiButtonIcon emotion-euiButtonIcon-s-empty-text-sideNavCollapseButton" data-test-subj="sideNavCollapseButton" - tabindex="-1" type="button" >
- +
diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap index bd3d8313bf8c7..c4de9479fb1f6 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap @@ -10,40 +10,44 @@ exports[`Collapsed mode should render the side navigation 1`] = `
- - - +
+
+
+ Solution +
+ +
+ + -
-
+ +
@@ -391,7 +399,6 @@ exports[`Collapsed mode should render the side navigation 1`] = ` aria-pressed="false" class="euiButtonIcon emotion-euiButtonIcon-s-empty-text-sideNavCollapseButton" data-test-subj="sideNavCollapseButton" - tabindex="-1" type="button" >
-
+ diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap index c61ec302b070b..2dd837f864d6f 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap @@ -11,33 +11,37 @@ exports[`Expanded mode should render the side navigation 1`] = ` class="kbnChromeNav-root css-vka7tb-getNavWrapperStyles" >
- -
-
-
- Solution -
-
+
+
+
+ Solution +
+ +

You are in the main navigation footer menu. Use Up and Down arrow keys to navigate the menu.

-
-
+ +
@@ -376,7 +384,6 @@ exports[`Expanded mode should render the side navigation 1`] = ` aria-pressed="true" class="euiButtonIcon emotion-euiButtonIcon-s-empty-text-sideNavCollapseButton" data-test-subj="sideNavCollapseButton" - tabindex="-1" type="button" >
-
+ diff --git a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx index a6d9f340902b1..fe9cd020b085b 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx +++ b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx @@ -1251,6 +1251,7 @@ describe('Both modes', () => { const serviceInventoryLink = within(sidePanel).getByTestId( sidePanelItemId('service-inventory') ); + const collapseButton = screen.getByTestId('sideNavCollapseButton'); act(() => { solutionLogo.focus(); @@ -1268,7 +1269,10 @@ describe('Both modes', () => { expect(gettingStartedLink).toHaveFocus(); - // Tab to side panel - should land on first focusable item (Service inventory) + await user.tab(); + + expect(collapseButton).toHaveFocus(); + await user.tab(); expect(serviceInventoryLink).toHaveFocus(); diff --git a/src/core/packages/chrome/navigation/src/components/footer/index.tsx b/src/core/packages/chrome/navigation/src/components/footer/index.tsx index a6e64bbcfd96f..89979aca3f2d3 100644 --- a/src/core/packages/chrome/navigation/src/components/footer/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/footer/index.tsx @@ -7,131 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { forwardRef, useMemo } from 'react'; -import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { - EuiHorizontalRule, - EuiScreenReaderOnly, - useEuiTheme, - useGeneratedHtmlId, - type UseEuiTheme, -} from '@elastic/eui'; - -import { FooterItem } from './item'; -import { getFocusableElements } from '../../utils/get_focusable_elements'; -import { handleRovingIndex } from '../../utils/handle_roving_index'; -import { updateTabIndices } from '../../utils/update_tab_indices'; -import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; -import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; - -const getFooterWrapperStyles = (euiThemeContext: UseEuiTheme, isCollapsed: boolean) => { - const { euiTheme: theme } = euiThemeContext; - return { - root: css` - align-items: center; - display: flex; - position: relative; - flex-direction: column; - gap: ${theme.size.xs}; - justify-content: center; - padding-top: ${isCollapsed ? theme.size.s : theme.size.m}; - - ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} - `, - collapseDivider: css` - position: relative; - background-color: transparent; - - ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} - `, - }; -}; - -export interface FooterIds { - footerNavigationInstructionsId: string; -} - -export type FooterChildren = ReactNode | ((ids: FooterIds) => ReactNode); - -export interface FooterProps { - children: FooterChildren; - isCollapsed: boolean; - collapseButton?: ReactNode; -} - -interface FooterComponent - extends ForwardRefExoticComponent> { - Item: typeof FooterItem; -} - -const FooterBase = forwardRef( - ({ children, isCollapsed, collapseButton }, ref) => { - const euiThemeContext = useEuiTheme(); - const footerNavigationInstructionsId = useGeneratedHtmlId({ - prefix: 'footer-navigation-instructions', - }); - - const handleRef = (node: HTMLElement | null) => { - if (typeof ref === 'function') { - ref(node); - } else if (ref) { - ref.current = node; - } - - if (node) { - const elements = getFocusableElements(node); - updateTabIndices(elements); - } - }; - - const wrapperStyles = useMemo( - () => getFooterWrapperStyles(euiThemeContext, isCollapsed), - [euiThemeContext, isCollapsed] - ); - - const renderChildren = () => { - if (typeof children === 'function') { - return children({ footerNavigationInstructionsId }); - } - return children; - }; - - return ( - <> - -

- {i18n.translate('core.ui.chrome.sideNavigation.footerInstructions', { - defaultMessage: - 'You are in the main navigation footer menu. Use Up and Down arrow keys to navigate the menu.', - })} -

-
- {/* The footer itself is not interactive but the children are */} - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} -
- {renderChildren()} - {collapseButton && ( - <> - - {collapseButton} - - )} -
- - ); - } -); - -export const Footer = Object.assign(FooterBase, { - Item: FooterItem, -}) satisfies FooterComponent; +export { FooterNav } from './nav'; +export type { FooterNavProps, FooterNavIds, FooterNavChildren } from './nav'; +export { FooterToolbar } from './toolbar'; +export type { FooterToolbarProps } from './toolbar'; +export { FooterItem } from './item'; diff --git a/src/core/packages/chrome/navigation/src/components/footer/item.tsx b/src/core/packages/chrome/navigation/src/components/footer/item.tsx index 984aed8939862..3b1747ba58b5e 100644 --- a/src/core/packages/chrome/navigation/src/components/footer/item.tsx +++ b/src/core/packages/chrome/navigation/src/components/footer/item.tsx @@ -7,135 +7,31 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { Suspense, forwardRef } from 'react'; -import type { KeyboardEvent, ForwardedRef, ComponentProps } from 'react'; -import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; -import type { EuiButtonIconProps, IconType } from '@elastic/eui'; -import { css } from '@emotion/react'; +import React, { forwardRef } from 'react'; +import type { KeyboardEvent, Ref } from 'react'; +import type { EuiButtonIconProps } from '@elastic/eui'; import type { MenuItem } from '../../../types'; -import { BetaBadge } from '../beta_badge'; -import { NAVIGATION_SELECTOR_PREFIX, TOOLTIP_OFFSET } from '../../constants'; -import { focusMainContent } from '../../utils/focus_main_content'; -import { useHighContrastModeStyles } from '../../hooks/use_high_contrast_mode_styles'; -import { useTooltip } from '../../hooks/use_tooltip'; -import { NewItemIndicator } from '../new_item_indicator'; +import { IconButton } from '../icon_button'; +import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; -export interface FooterItemProps extends Omit, MenuItem { +export interface FooterItemProps + extends Omit, + Omit { hasContent?: boolean; - iconType: IconType; isCurrent?: boolean; isHighlighted: boolean; isNew: boolean; - label: string; onClick?: () => void; onKeyDown?: (e: KeyboardEvent) => void; } -/** - * A footer item that leverages the "Toggle button" pattern from EUI. - * - * @see {@link https://eui.elastic.co/docs/components/navigation/buttons/button/#toggle-button} - */ export const FooterItem = forwardRef( - ( - { badgeType, hasContent, iconType, id, isCurrent, isHighlighted, isNew, label, ...props }, - ref: ForwardedRef - ) => { - const { euiTheme } = useEuiTheme(); - const { tooltipRef, handleMouseOut } = useTooltip(); - const highContrastModeStyles = useHighContrastModeStyles(); - - const handleFooterItemKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - // Required for entering the popover with Enter or Space key - // Otherwise the navigation happens immediately - e.preventDefault(); - focusMainContent(); - } - }; - - const wrapperStyles = css` - display: flex; - justify-content: center; - width: 100%; - `; - - const buttonStyles = css` - --high-contrast-hover-indicator-color: ${isHighlighted - ? euiTheme.colors.textPrimary - : euiTheme.colors.textParagraph}; - ${highContrastModeStyles} - `; - - const buttonWrapperStyles = css` - position: relative; - display: inline-flex; - `; - - const footerItemTestSubj = `${NAVIGATION_SELECTOR_PREFIX}-footerItem-${id}`; - - const buttonProps: ComponentProps & { - 'data-highlighted': string; - 'data-menu-item': string; - } = { - 'aria-current': isCurrent ? 'page' : undefined, - 'aria-label': label, - buttonRef: ref, - color: isHighlighted ? 'primary' : 'text', - 'data-highlighted': isHighlighted ? 'true' : 'false', - 'data-test-subj': footerItemTestSubj, - 'data-menu-item': 'true', - display: isHighlighted ? 'base' : 'empty', - iconType: 'empty', // `iconType` is passed in Suspense below - onKeyDown: handleFooterItemKeyDown, - size: 's', - css: buttonStyles, - ...props, - }; - - const menuItem = ( -
- }> - - - {isNew && } -
- ); - - if (!hasContent) { - const tooltipStyles = css` - display: flex; - align-items: center; - gap: ${euiTheme.size.s}; - `; - const tooltipContent = badgeType ? ( - - {label} - - - ) : ( - label - ); - - return ( - - {menuItem} - - ); - } - - return
{menuItem}
; - } + ({ id, ...props }, ref) => ( + } + data-test-subj={`${NAVIGATION_SELECTOR_PREFIX}-footerItem-${id}`} + {...props} + /> + ) ); diff --git a/src/core/packages/chrome/navigation/src/components/footer/nav.tsx b/src/core/packages/chrome/navigation/src/components/footer/nav.tsx new file mode 100644 index 0000000000000..19808bac162be --- /dev/null +++ b/src/core/packages/chrome/navigation/src/components/footer/nav.tsx @@ -0,0 +1,118 @@ +/* + * 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 React, { forwardRef, useMemo } from 'react'; +import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { + EuiScreenReaderOnly, + useEuiTheme, + useGeneratedHtmlId, + type UseEuiTheme, +} from '@elastic/eui'; + +import { FooterItem } from './item'; +import { getFocusableElements } from '../../utils/get_focusable_elements'; +import { handleRovingIndex } from '../../utils/handle_roving_index'; +import { updateTabIndices } from '../../utils/update_tab_indices'; +import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; +import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; + +const getFooterNavWrapperStyles = (euiThemeContext: UseEuiTheme, isCollapsed: boolean) => { + const { euiTheme: theme } = euiThemeContext; + return css` + align-items: center; + display: flex; + position: relative; + flex-direction: column; + gap: ${theme.size.xs}; + justify-content: center; + padding-top: ${isCollapsed ? theme.size.s : theme.size.m}; + + ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} + `; +}; + +export interface FooterNavIds { + footerNavigationInstructionsId: string; +} + +export type FooterNavChildren = ReactNode | ((ids: FooterNavIds) => ReactNode); + +export interface FooterNavProps { + children: FooterNavChildren; + isCollapsed: boolean; +} + +interface FooterNavComponent + extends ForwardRefExoticComponent> { + Item: typeof FooterItem; +} + +const FooterNavBase = forwardRef(({ children, isCollapsed }, ref) => { + const euiThemeContext = useEuiTheme(); + const footerNavigationInstructionsId = useGeneratedHtmlId({ + prefix: 'footer-navigation-instructions', + }); + + const handleRef = (node: HTMLElement | null) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + + if (node) { + const elements = getFocusableElements(node); + updateTabIndices(elements); + } + }; + + const wrapperStyles = useMemo( + () => getFooterNavWrapperStyles(euiThemeContext, isCollapsed), + [euiThemeContext, isCollapsed] + ); + + const renderChildren = () => { + if (typeof children === 'function') { + return children({ footerNavigationInstructionsId }); + } + return children; + }; + + return ( + <> + +

+ {i18n.translate('core.ui.chrome.sideNavigation.footerInstructions', { + defaultMessage: + 'You are in the main navigation footer menu. Use Up and Down arrow keys to navigate the menu.', + })} +

+
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} + + + ); +}); + +export const FooterNav = Object.assign(FooterNavBase, { + Item: FooterItem, +}) satisfies FooterNavComponent; diff --git a/src/core/packages/chrome/navigation/src/components/footer/toolbar.tsx b/src/core/packages/chrome/navigation/src/components/footer/toolbar.tsx new file mode 100644 index 0000000000000..ec5ee70b40e35 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/components/footer/toolbar.tsx @@ -0,0 +1,73 @@ +/* + * 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 React, { forwardRef, useMemo } from 'react'; +import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; +import { css } from '@emotion/react'; +import { useEuiTheme, type UseEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ToolItem } from '../tool_item'; +import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; +import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; + +const getFooterToolbarWrapperStyles = (euiThemeContext: UseEuiTheme, isCollapsed: boolean) => { + const { euiTheme: theme } = euiThemeContext; + return css` + align-items: center; + display: flex; + flex-direction: column; + gap: ${theme.size.xs}; + justify-content: center; + padding-top: ${isCollapsed ? theme.size.s : theme.size.m}; + position: relative; + width: 100%; + + ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} + `; +}; + +export interface FooterToolbarProps { + children?: ReactNode; + isCollapsed: boolean; +} + +interface FooterToolbarComponent + extends ForwardRefExoticComponent> { + Item: typeof ToolItem; +} + +const FooterToolbarBase = forwardRef( + ({ children, isCollapsed }, ref) => { + const euiThemeContext = useEuiTheme(); + + const wrapperStyles = useMemo( + () => getFooterToolbarWrapperStyles(euiThemeContext, isCollapsed), + [euiThemeContext, isCollapsed] + ); + + return ( +
+ {children} +
+ ); + } +); + +export const FooterToolbar = Object.assign(FooterToolbarBase, { + Item: ToolItem, +}) satisfies FooterToolbarComponent; diff --git a/src/core/packages/chrome/navigation/src/components/header/index.tsx b/src/core/packages/chrome/navigation/src/components/header/index.tsx new file mode 100644 index 0000000000000..d97146f9f8424 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/components/header/index.tsx @@ -0,0 +1,52 @@ +/* + * 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 React, { forwardRef } from 'react'; +import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { useEuiTheme } from '@elastic/eui'; + +import { ToolItem } from '../tool_item'; +import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; + +export interface HeaderToolbarProps { + children: ReactNode; +} + +export const HeaderToolbar = forwardRef(({ children }, ref) => { + const { euiTheme } = useEuiTheme(); + + const wrapperStyles = css` + align-items: center; + display: flex; + flex-direction: column; + gap: ${euiTheme.size.xs}; + justify-content: center; + width: 100%; + `; + + return ( +
+ {children} +
+ ); +}) as ForwardRefExoticComponent> & { + Item: typeof ToolItem; +}; + +HeaderToolbar.Item = ToolItem; diff --git a/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx b/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx new file mode 100644 index 0000000000000..a2ca9fae5a477 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx @@ -0,0 +1,122 @@ +/* + * 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 React, { Suspense, forwardRef } from 'react'; +import type { KeyboardEvent, ComponentProps, Ref } from 'react'; +import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import type { EuiButtonIconProps, IconType } from '@elastic/eui'; +import { css } from '@emotion/react'; + +import type { BadgeType } from '../../../types'; +import { BetaBadge } from '../beta_badge'; +import { TOOLTIP_OFFSET } from '../../constants'; +import { useHighContrastModeStyles } from '../../hooks/use_high_contrast_mode_styles'; +import { useTooltip } from '../../hooks/use_tooltip'; +import { NewItemIndicator } from '../new_item_indicator'; + +export interface IconButtonProps + extends Omit { + badgeType?: BadgeType; + hasContent?: boolean; + iconType: IconType; + isCurrent?: boolean; + isHighlighted: boolean; + isNew: boolean; + label: string; + onClick?: () => void; + onKeyDown?: (e: KeyboardEvent) => void; +} + +export const IconButton = forwardRef( + ({ badgeType, hasContent, iconType, isCurrent, isHighlighted, isNew, label, ...props }, ref) => { + const buttonRef = ref as Ref; + const { euiTheme } = useEuiTheme(); + const { tooltipRef, handleMouseOut } = useTooltip(); + const highContrastModeStyles = useHighContrastModeStyles(); + + const wrapperStyles = css` + display: flex; + justify-content: center; + width: 100%; + `; + + const buttonStyles = css` + --high-contrast-hover-indicator-color: ${isHighlighted + ? euiTheme.colors.textPrimary + : euiTheme.colors.textParagraph}; + ${highContrastModeStyles} + `; + + const buttonWrapperStyles = css` + position: relative; + display: inline-flex; + `; + + const buttonProps: ComponentProps & { + 'data-highlighted': string; + 'data-menu-item': string; + } = { + 'aria-current': isCurrent ? 'page' : undefined, + 'aria-label': label, + buttonRef, + color: isHighlighted ? 'primary' : 'text', + 'data-highlighted': isHighlighted ? 'true' : 'false', + 'data-menu-item': 'true', + display: isHighlighted ? 'base' : 'empty', + iconType: 'empty', + size: 's', + css: buttonStyles, + ...props, + }; + + const item = ( +
+ }> + + + {isNew && } +
+ ); + + if (!hasContent) { + const tooltipStyles = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + `; + const tooltipContent = badgeType ? ( + + {label} + + + ) : ( + label + ); + + return ( + + {item} + + ); + } + + return
{item}
; + } +); diff --git a/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx b/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx index 2082d94cdb256..47aceca945318 100644 --- a/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx @@ -20,6 +20,7 @@ import { NewItemIndicator } from '../new_item_indicator'; interface MenuItemBaseProps { children: ReactNode; iconType: IconType; + iconColor?: 'default' | 'text'; id?: string; isCurrent?: boolean; isHighlighted: boolean; @@ -54,6 +55,7 @@ export const MenuItem = forwardRef
}> - + {isNew && }
diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 9a322be8e69d1..c0c56fec98be6 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -7,13 +7,26 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useState, type ReactNode } from 'react'; +import React, { + useMemo, + useState, + type ForwardRefExoticComponent, + type ReactNode, + type RefAttributes, +} from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { useIsWithinBreakpoints } from '@elastic/eui'; +import { useEuiTheme, useIsWithinBreakpoints, type UseEuiTheme } from '@elastic/eui'; -import type { NavigationStructure, SideNavLogo, MenuItem, SecondaryMenuItem } from '../../types'; +import type { + MenuItem, + NavigationStructure, + SecondaryMenuItem, + SideNavLogo, + ToolItem, + ToolSlots, +} from '../../types'; import { MAIN_PANEL_ID, MAX_FOOTER_ITEMS, @@ -21,6 +34,7 @@ import { NAVIGATION_ROOT_SELECTOR, NAVIGATION_SELECTOR_PREFIX, } from '../constants'; +import type { ToolItemProps } from './tool_item'; import { SideNav } from './side_nav'; import { SideNavCollapseButton } from './collapse_button'; import { focusMainContent } from '../utils/focus_main_content'; @@ -29,11 +43,30 @@ import { useLayoutWidth } from '../hooks/use_layout_width'; import { useNavigation } from '../hooks/use_navigation'; import { useNewItems } from '../hooks/use_new_items'; import { useResponsiveMenu } from '../hooks/use_responsive_menu'; +import { getHighContrastSeparator } from '../hooks/use_high_contrast_mode_styles'; const navigationWrapperStyles = css` display: flex; `; +const getTopSectionStyles = (euiThemeContext: UseEuiTheme) => { + const { euiTheme } = euiThemeContext; + return css` + align-items: center; + display: flex; + flex-direction: column; + gap: ${euiTheme.size.s}; + justify-content: center; + padding-bottom: ${euiTheme.size.s}; + position: relative; + width: 100%; + + ${getHighContrastSeparator(euiThemeContext)} + `; +}; + +type SelectableNavigationItem = MenuItem | SecondaryMenuItem | SideNavLogo; + export interface NavigationProps { /** * The active path for the navigation, used for highlighting the current item. @@ -44,9 +77,13 @@ export interface NavigationProps { */ isCollapsed: boolean; /** - * The navigation structure containing primary, secondary, and footer items. + * The navigation structure containing primary and footer navigable items. */ items: NavigationStructure; + /** + * Optional chrome tools (search, help, etc.) rendered in header and footer toolbars. + */ + tools?: ToolSlots; /** * The logo object containing the route ID, href, label, and type. */ @@ -56,9 +93,9 @@ export interface NavigationProps { */ setWidth: (width: number) => void; /** - * (optional) Callback fired when a navigation item is clicked. + * (optional) Callback fired when a navigable item (or logo) is activated. Not called for tool controls. */ - onItemClick?: (item: MenuItem | SecondaryMenuItem | SideNavLogo) => void; + onItemClick?: (item: SelectableNavigationItem) => void; /** * Callback fired when the collapse button is toggled. * @@ -80,6 +117,7 @@ export const Navigation = ({ activeItemId, isCollapsed: isCollapsedProp, items, + tools, logo, onItemClick, onToggleCollapsed, @@ -87,13 +125,15 @@ export const Navigation = ({ sidePanelFooter, ...rest }: NavigationProps) => { + const euiThemeContext = useEuiTheme(); const forcedCollapsed = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = forcedCollapsed || isCollapsedProp; const popoverItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverItem`; const popoverFooterItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverFooterItem`; + const popoverFooterToolPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverFooterTool`; + const popoverHeaderToolPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverHeaderTool`; const sidePanelItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-sidePanelItem`; const moreMenuTriggerTestSubj = `${NAVIGATION_SELECTOR_PREFIX}-moreMenuTrigger`; - const { actualActiveItemId, visuallyActivePageId, @@ -118,11 +158,18 @@ export const Navigation = ({ useLayoutWidth({ isCollapsed, isSidePanelOpen, setWidth }); - // Create the collapse button if a toggle callback is provided or if the navigation is not forced to be collapsed (e.g. on mobile) - const collapseButton = - onToggleCollapsed && !forcedCollapsed ? ( - - ) : null; + const collapseToggle = onToggleCollapsed && !forcedCollapsed ? onToggleCollapsed : undefined; + + const headerTools = tools?.headerTools ?? []; + const footerTools = tools?.footerTools ?? []; + const hasHeaderTools = headerTools.length > 0; + const hasFooterTools = footerTools.length > 0; + const showFooterToolbar = hasFooterTools || Boolean(collapseToggle); + + const topSectionStyles = useMemo(() => getTopSectionStyles(euiThemeContext), [euiThemeContext]); + + const footerNavItems = items.footerItems.slice(0, MAX_FOOTER_ITEMS); + const hasFooterNavItems = footerNavItems.length > 0; return (
- onItemClick?.(logo)} - {...logo} - /> +
+ onItemClick?.(logo)} + {...logo} + /> + {hasHeaderTools && ( + + {renderToolItems({ + items: headerTools, + itemComponent: SideNav.HeaderToolbar.Item, + isAnyPopoverLocked, + popoverItemPrefix: popoverHeaderToolPrefix, + })} + + )} +
{({ mainNavigationInstructionsId }) => ( @@ -341,84 +400,42 @@ export const Navigation = ({ )} - - {({ footerNavigationInstructionsId }) => ( - <> - {items.footerItems.slice(0, MAX_FOOTER_ITEMS).map((item, index) => { - const { sections, ...itemProps } = item; - const isFirstItem = index === 0; - const ariaDescribedBy = isFirstItem ? footerNavigationInstructionsId : undefined; - - return ( - onItemClick?.(item)} - {...itemProps} - /> - } - > - {(closePopover, ids) => ( - - {sections?.map((section, sectionIndex) => { - const firstNonEmptySectionIndex = item.sections?.findIndex( - (s) => s.items.length > 0 - ); - return ( - - {section.items.map((subItem, subItemIndex) => { - const isFirstSubItem = - sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; - const subItemAriaDescribedBy = isFirstSubItem - ? ids?.popoverNavigationInstructionsId - : undefined; + {hasFooterNavItems && ( + + {({ footerNavigationInstructionsId }) => + renderFooterNavItems({ + items: footerNavItems, + itemComponent: SideNav.FooterNav.Item, + navigationInstructionsId: footerNavigationInstructionsId, + openerNode, + isCollapsed, + isAnyPopoverLocked, + visuallyActivePageId, + visuallyActiveSubpageId, + actualActiveItemId, + onItemClick, + getIsNewPrimary, + getIsNewSecondary, + popoverItemPrefix: popoverFooterItemPrefix, + }) + } + + )} - return ( - { - onItemClick?.(subItem); - if (subItem.href) { - closePopover(); - } - }} - {...subItem} - testSubjPrefix={popoverFooterItemPrefix} - > - {subItem.label} - - ); - })} - - ); - })} - - )} - - ); + {showFooterToolbar && ( + + {hasFooterTools && + renderToolItems({ + items: footerTools, + itemComponent: SideNav.FooterToolbar.Item, + isAnyPopoverLocked, + popoverItemPrefix: popoverFooterToolPrefix, })} - - )} - + {collapseToggle && ( + + )} + + )}
{isSidePanelOpen && openerNode && ( @@ -469,3 +486,183 @@ export const Navigation = ({
); }; + +interface RenderFooterNavItemsArgs { + actualActiveItemId?: string; + getIsNewPrimary: (itemId: string) => boolean; + getIsNewSecondary: (itemId: string) => boolean; + isAnyPopoverLocked: boolean; + isCollapsed: boolean; + itemComponent: typeof SideNav.FooterNav.Item; + items: MenuItem[]; + navigationInstructionsId: string | undefined; + onItemClick?: (item: SelectableNavigationItem) => void; + openerNode: MenuItem | null; + popoverItemPrefix: string; + visuallyActivePageId?: string; + visuallyActiveSubpageId?: string; +} + +const renderFooterNavItems = ({ + actualActiveItemId, + getIsNewPrimary, + getIsNewSecondary, + isAnyPopoverLocked, + isCollapsed, + itemComponent: ItemComponent, + items, + navigationInstructionsId, + onItemClick, + openerNode, + popoverItemPrefix, + visuallyActivePageId, + visuallyActiveSubpageId, +}: RenderFooterNavItemsArgs) => + items.map((item, index) => { + const ariaDescribedBy = index === 0 ? navigationInstructionsId : undefined; + const { sections, ...itemProps } = item; + + return ( + onItemClick?.(item)} + {...itemProps} + /> + } + > + {(closePopover, ids) => { + const firstNonEmptySectionIndex = sections?.findIndex( + (section) => section.items.length > 0 + ); + return ( + + {sections?.map((section, sectionIndex) => ( + + {section.items.map((subItem, subItemIndex) => { + const isFirstSubItem = + sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; + const subItemAriaDescribedBy = isFirstSubItem + ? ids?.popoverNavigationInstructionsId + : undefined; + + return ( + { + onItemClick?.(subItem); + if (subItem.href) { + closePopover(); + } + }} + {...subItem} + testSubjPrefix={popoverItemPrefix} + > + {subItem.label} + + ); + })} + + ))} + + ); + }} + + ); + }); + +interface RenderToolItemsArgs { + isAnyPopoverLocked: boolean; + itemComponent: ForwardRefExoticComponent>; + items: ToolItem[]; + popoverItemPrefix: string; +} + +const renderToolItems = ({ + isAnyPopoverLocked, + itemComponent: ItemComponent, + items, + popoverItemPrefix, +}: RenderToolItemsArgs) => + items.map((item) => { + const { onClick: itemOnClick, sections, ...itemProps } = item; + + return ( + { + itemOnClick?.(); + }} + {...itemProps} + /> + } + > + {(closePopover, ids) => { + const firstNonEmptySectionIndex = sections?.findIndex( + (section) => section.items.length > 0 + ); + return ( + + {sections?.map((section, sectionIndex) => ( + + {section.items.map((subItem, subItemIndex) => { + const isFirstSubItem = + sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; + const subItemAriaDescribedBy = isFirstSubItem + ? ids?.popoverNavigationInstructionsId + : undefined; + + return ( + { + closePopover(); + }} + {...subItem} + testSubjPrefix={popoverItemPrefix} + > + {subItem.label} + + ); + })} + + ))} + + ); + }} + + ); + }); diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx index f9a15f38e5b47..6eb31ffd09d06 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx @@ -13,7 +13,8 @@ import { css } from '@emotion/react'; import { useEuiTheme, type UseEuiTheme } from '@elastic/eui'; import { COLLAPSED_WIDTH, EXPANDED_WIDTH } from '../../hooks/use_layout_width'; -import { Footer } from '../footer'; +import { FooterNav, FooterToolbar } from '../footer'; +import { HeaderToolbar } from '../header'; import { Logo } from './logo'; import { NestedSecondaryMenu } from '../nested_secondary_menu'; import { Popover } from './popover'; @@ -40,21 +41,24 @@ export interface SideNavProps { interface SideNavComponent extends FC { Logo: typeof Logo; + HeaderToolbar: typeof HeaderToolbar; PrimaryMenu: typeof PrimaryMenu; Popover: typeof Popover; SecondaryMenu: typeof SecondaryMenu; NestedSecondaryMenu: typeof NestedSecondaryMenu; - Footer: typeof Footer; + FooterNav: typeof FooterNav; + FooterToolbar: typeof FooterToolbar; SidePanel: typeof SidePanel; } /** * A wrapper component for the side navigation that encapsulates: * - the logo, + * - header and footer toolbars, * - the primary menu, * - the secondary menu used in the popover and in the side panel, * - the nested secondary menu used in the "More" menu, - * - the footer, + * - the footer navigation and toolbar, * - the side panel. */ export const SideNav: SideNavComponent = ({ children, isCollapsed }) => { @@ -73,9 +77,11 @@ export const SideNav: SideNavComponent = ({ children, isCollapsed }) => { }; SideNav.Logo = Logo; +SideNav.HeaderToolbar = HeaderToolbar; SideNav.PrimaryMenu = PrimaryMenu; SideNav.Popover = Popover; SideNav.SecondaryMenu = SecondaryMenu; SideNav.NestedSecondaryMenu = NestedSecondaryMenu; -SideNav.Footer = Footer; +SideNav.FooterNav = FooterNav; +SideNav.FooterToolbar = FooterToolbar; SideNav.SidePanel = SidePanel; diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx index c9edaefa15c88..8b5008a3f903e 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx @@ -17,7 +17,6 @@ import type { SideNavLogo } from '../../../types'; import { MenuItem } from '../menu_item'; import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; import { useTooltip } from '../../hooks/use_tooltip'; -import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; export interface LogoProps extends Omit, 'onClick'>, SideNavLogo { id: string; @@ -31,30 +30,16 @@ export const Logo = ({ isCollapsed, isCurrent, isHighlighted, + hideLabel, label, ...props }: LogoProps): JSX.Element => { - const euiThemeContext = useEuiTheme(); - const { euiTheme } = euiThemeContext; + const { euiTheme } = useEuiTheme(); const { tooltipRef, handleMouseOut } = useTooltip(); - /** - * **Icon size** - * - * In Figma, the logo icon is 20x20. - * `EuiIcon` supports `l` which is 24x24 and `m` which is 16x16. - * - * **Padding** - * - * 7px aligns better with other elements in the layout. - * We cannot use `euiTheme.size.s` because it's 8px. - */ const wrapperStyles = css` position: relative; padding-top: ${isCollapsed ? euiTheme.size.s : euiTheme.size.m}; - padding-bottom: ${isCollapsed ? euiTheme.size.s : euiTheme.size.m}; - - ${getHighContrastSeparator(euiThemeContext)} .euiText { font-weight: ${euiTheme.font.weight.bold}; @@ -79,7 +64,7 @@ export const Logo = ({ data-test-subj={logoTestSubj} isHighlighted={isHighlighted} isCurrent={isCurrent} - isLabelVisible={!isCollapsed} + isLabelVisible={!isCollapsed && !hideLabel} isTruncated={false} {...props} > diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx index 2e4f837ef5b8d..6bc9fe5efb2f6 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx @@ -59,7 +59,7 @@ export interface SidePanelProps { } /** - * Side navigation panel that opens on mouse click if the primary menu item contains a submenu. + * Side navigation panel that opens on mouse click if the top-level item contains a submenu. * Shows only in expanded mode. */ export const SidePanel = ({ children, footer, openerNode }: SidePanelProps): JSX.Element => { diff --git a/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx b/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx new file mode 100644 index 0000000000000..ec4b27dbf0a21 --- /dev/null +++ b/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx @@ -0,0 +1,36 @@ +/* + * 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 React, { forwardRef } from 'react'; +import type { KeyboardEvent, Ref } from 'react'; +import type { EuiButtonIconProps } from '@elastic/eui'; + +import type { ToolItem as ToolItemData } from '../../../types'; +import { IconButton } from '../icon_button'; +import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; + +export interface ToolItemProps + extends Omit, + Omit { + hasContent?: boolean; + isHighlighted: boolean; + isNew: boolean; + onClick?: () => void; + onKeyDown?: (e: KeyboardEvent) => void; +} + +export const ToolItem = forwardRef( + ({ id, ...props }, ref) => ( + } + data-test-subj={`${NAVIGATION_SELECTOR_PREFIX}-toolItem-${id}`} + {...props} + /> + ) +); diff --git a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts index 01620f070cfe5..a94a690fa2d83 100644 --- a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts +++ b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts @@ -32,7 +32,7 @@ interface NavigationState { * - `actualActiveItemId` - the actual active item ID. There can only be one `aria-current=page` link on the page. * - `visuallyActivePageId` - the visually active page ID. The link does not have to be `aria-current=page`, it can be a parent of an active page. * - `visuallyActiveSubpageId` - the visually active subpage ID. - * - `openerNode` - the primary menu item whose submenu is shown in the side panel. + * - `openerNode` - the top-level item whose submenu is shown in the side panel. * - `isCollapsed` - whether the side nav is collapsed. * - `isSidePanelOpen` - whether the side panel is open. */ diff --git a/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts b/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts index 3fc92540427c1..70ac2e41938ec 100644 --- a/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts +++ b/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts @@ -7,14 +7,12 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { MenuItem } from '../../types'; - /** * Utility function for checking whether the menu item has a submenu. * * @param item - the menu item to check. * @returns `true` if the menu item has a submenu, `false` otherwise. */ -export const getHasSubmenu = (item: MenuItem): boolean => { +export const getHasSubmenu = (item: { sections?: ReadonlyArray }): boolean => { return !!item.sections && item.sections.length > 0; }; diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index ad53ab969ba16..690fd4f73f9b5 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -13,7 +13,7 @@ export type BadgeType = 'beta' | 'techPreview' | 'new'; /** * A navigation item within a secondary/nested menu. - * Secondary items appear when a primary menu item with sections is clicked or hovered. + * Secondary items appear when a top-level navigation item with sections is clicked or hovered. */ export interface SecondaryMenuItem { /** @@ -63,7 +63,7 @@ export interface SecondaryMenuSection { /** * A primary navigation menu item that appears in the main navigation sidebar. - * Can optionally contain nested secondary menu sections. + * Primary items must always be navigable links. */ export interface MenuItem { /** @@ -97,12 +97,36 @@ export interface MenuItem { } /** - * The complete navigation structure containing primary and footer menu items. - * This is the main data structure passed to the Navigation component. + * A chrome tool control (search, help, profile, etc.) — not a navigation destination. + * Tool items can perform an action via `onClick` or expose submenu content via `sections`. + */ +export interface ToolItem { + id: string; + label: string; + iconType: IconType; + onClick?: () => void; + /** + * Optional submenu content shown for the tool control. + */ + sections?: SecondaryMenuSection[]; + badgeType?: BadgeType; + 'data-test-subj'?: string; +} + +/** + * Optional groupings of tool controls for the sidenav header and footer toolbars. + */ +export interface ToolSlots { + headerTools?: ToolItem[]; + footerTools?: ToolItem[]; +} + +/** + * Navigable sidenav content: primary rail and footer links. */ export interface NavigationStructure { /** - * The items to be displayed in the navigation footer. + * The items to be displayed in the navigation footer (navigable links). */ footerItems: MenuItem[]; /** @@ -139,10 +163,20 @@ export interface SideNavLogo { * The label for the logo, typically the product name. */ label: string; + /** + * When `true`, the label is not rendered under the icon while the navigation is expanded. + * The logo link still uses `label` for `aria-label` and for the collapsed-mode tooltip. + */ + hideLabel?: boolean; /** * The logo type, e.g. `appObservability`, `appSecurity`, etc. */ iconType: string; + /** + * (optional) Color of the logo icon. `'default'` renders the icon in its brand colors; + * `'text'` renders it monochromatically using the current text color. + */ + iconColor?: 'default' | 'text'; /** * (optional) `data-test-subj` attribute for testing and tracking purposes. */ diff --git a/src/platform/plugins/shared/dashboard/moon.yml b/src/platform/plugins/shared/dashboard/moon.yml index 8380b6aef1f8a..771445293aef7 100644 --- a/src/platform/plugins/shared/dashboard/moon.yml +++ b/src/platform/plugins/shared/dashboard/moon.yml @@ -118,8 +118,8 @@ dependsOn: - '@kbn/test-jest-helpers' - '@kbn/scout' - '@kbn/cps-utils' - - '@kbn/core-chrome-browser' - '@kbn/as-code-shared-schemas' + - '@kbn/core-chrome-browser' tags: - plugin - prod diff --git a/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx b/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx index 3b9ecfa6d024f..80ec27848aacf 100644 --- a/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx +++ b/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx @@ -40,6 +40,14 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}, {}, GlobalSearchBar const { application, http } = core; const reportEvent = new EventReporter({ analytics: core.analytics, usageCollection }); + core.chrome.next.globalSearch.set({ + onClick: () => { + // TODO: open search modal + // eslint-disable-next-line no-console + console.log('[GlobalSearch] search button clicked — modal not yet implemented'); + }, + }); + core.chrome.navControls.registerCenter({ order: 1000, content: ( From 9cdd1286dff04389616839b24172a73186930837 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 2 Apr 2026 13:22:29 +0200 Subject: [PATCH 12/77] fix sidebar margin-offset --- .../sidebar/layout_sidebar.styles.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/packages/chrome/layout/core-chrome-layout-components/sidebar/layout_sidebar.styles.ts b/src/core/packages/chrome/layout/core-chrome-layout-components/sidebar/layout_sidebar.styles.ts index 56505dc5570bf..29dc67df81c61 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout-components/sidebar/layout_sidebar.styles.ts +++ b/src/core/packages/chrome/layout/core-chrome-layout-components/sidebar/layout_sidebar.styles.ts @@ -18,6 +18,8 @@ const root = css` 100% - ${layoutVar('application.marginTop')} - ${layoutVar('application.marginBottom')} ); width: calc(100% - ${layoutVar('application.marginRight')}); + margin-top: ${layoutVar('application.marginTop')}; + margin-bottom: ${layoutVar('application.marginBottom')}; z-index: ${layoutLevels.sidebar}; min-height: 0; // to allow flex children to shrink properly `; From f9e3ae42a32b37a43c467c24c92532ffa0e200fd Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 2 Apr 2026 13:22:55 +0200 Subject: [PATCH 13/77] put sidenav tools behind feature flag --- .../project/sidenav/navigation/navigation.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx index 287af2fb38645..0bdcd314a5448 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx @@ -96,14 +96,18 @@ const useNavigationItems = (): NavigationState | null => { }) ); - const toolSlots$ = combineLatest([chrome.next.globalSearch.get$(), helpLinks$]).pipe( - map(([searchConfig, helpLinks]) => - buildToolSlots({ - globalSearch: searchConfig, - helpLinks, - }) - ) - ); + const emptyToolSlots: ToolSlots = { headerTools: [], footerTools: [] }; + + const toolSlots$ = isNextChrome + ? combineLatest([chrome.next.globalSearch.get$(), helpLinks$]).pipe( + map(([searchConfig, helpLinks]) => + buildToolSlots({ + globalSearch: searchConfig, + helpLinks, + }) + ) + ) + : [emptyToolSlots]; return combineLatest([navState$, toolSlots$]).pipe( map(([navState, toolSlots]) => ({ From c63aacee830ba92d8835d4bb54336531b4eecda9 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 2 Apr 2026 13:23:21 +0200 Subject: [PATCH 14/77] fix securty-solution not rendered without ai assistant --- .../plugins/security_solution/public/assistant/provider.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx index 74535ab610cef..f706e7128ea48 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx @@ -56,7 +56,11 @@ export const AssistantProvider: FC> = ({ children }) }, [elasticAssistantSharedState.promptContexts, promptContext, promptContexts]); if (!assistantContextValue) { - return null; + // TODO: Chrome-Next quick mitigation — was `return null` which blocked the entire route tree. + // With Chrome Next, the elastic_assistant nav control never mounts (no HeaderNavControls), + // so setAssistantContextValue is never called and this stays undefined. + // See https://github.com/elastic/kibana/issues/260010 + return <>{children}; } return ( From bc6afc7986298213ec493f66d01104e8a355b091 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 2 Apr 2026 17:26:06 +0200 Subject: [PATCH 15/77] [FeatureBranch/DoNotReview][Chrome Next] Keep all AI buttons (#260937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the AI assistant buttons not appearing in the Chrome Next header. Relates to https://github.com/elastic/kibana/issues/260010 ### Problem Chrome Next renders the header differently from the legacy chrome — it does **not** render `HeaderNavControls` (the `chrome.navControls.registerRight` mount points). All AI assistant buttons (Security, Observability, Search, AI experience picker) relied exclusively on `registerRight`, so they were invisible when Chrome Next was enabled. Additionally, the existing `chrome.next.aiButton.set()` API was a single-slot, last-write-wins design — only the Agent Builder could use it, and other AI experiences had no way to register their buttons. ### Changes **1. Multi-registration `aiButton.register()` API** Replaced `chrome.next.aiButton.set(node)` with `chrome.next.aiButton.register(button)`: - Multiple plugins can register buttons; each call returns an unregister callback - Accepts `ReactNode | MountPoint` as content (via `ChromeExtensionContent`), so plugins can reuse their existing `mount` functions - Internal state uses `BehaviorSubject>` to manage registrations - Chrome Next header renders all registered buttons via `AiButtonSlot` **2. Dual registration in all AI plugins** Each AI plugin now registers with **both** `chrome.navControls.registerRight` (legacy) and `chrome.next.aiButton.register` (Chrome Next). This ensures buttons appear regardless of which chrome is active. All dual registrations are marked with `// TODO: Chrome-Next hack` comments linking to #260010 for cleanup once Chrome Next is the only chrome. Plugins updated: - `elastic_assistant` (Security AI Assistant) - `observability_ai_assistant_app` (Observability AI Assistant) - `search_assistant` (Search AI Assistant) - `ai_assistant_management/selection` (AI experience picker) - `agent_builder` (Agent Builder — already had Chrome Next registration, updated to new API) **3. Type consolidation** Moved all Chrome Next types into `src/core/packages/chrome/browser/src/chrome_next/`: - `ChromeNextAiButton`, `ChromeNextHeaderConfig`, `ChromeNextGlobalSearchConfig`, `ChromeNext` - Extracted `InternalChromeNext` interface in `browser-internal-types` **4. Empty mount point layout fix** Fixed phantom gaps in the Chrome Next trailing actions caused by AI buttons that register but render nothing (e.g., when a solution's assistant is not enabled). Applied `mountPointContainerCss` to both the `MountPoint` and `ReactNode` branches in `HeaderExtension` so empty wrappers collapse out of the flex layout. --- .../src/project_next/ai_button_slot.tsx | 19 ++++--- .../src/project_next/hooks/index.ts | 2 +- .../src/project_next/hooks/use_ai_button.ts | 14 +++--- .../src/project_next/trailing_actions.tsx | 6 +-- .../src/shared/header_extension.tsx | 2 +- .../chrome/browser-internal-types/index.ts | 25 ++++++---- .../browser-internal/src/chrome_api.tsx | 14 +++++- .../src/state/chrome_state.ts | 5 +- .../browser-mocks/src/chrome_service.mock.ts | 4 +- src/core/packages/chrome/browser/index.ts | 8 +-- .../browser/src/chrome_next/ai_button.ts | 15 ++++++ .../browser/src/chrome_next/chrome_next.ts | 47 +++++++++++++++++ .../global_search.ts} | 0 .../{next_header.ts => chrome_next/header.ts} | 0 .../chrome/browser/src/chrome_next/index.ts | 21 ++++++++ .../packages/chrome/browser/src/contracts.ts | 37 ++------------ src/core/packages/chrome/browser/src/index.ts | 11 ++-- .../selection/public/plugin.tsx | 50 +++++++++++-------- .../shared/agent_builder/public/plugin.tsx | 19 ++++--- .../public/plugin.tsx | 42 +++++++++------- .../search_assistant/public/plugin.tsx | 40 +++++++++------ .../elastic_assistant/public/plugin.tsx | 10 ++++ .../public/assistant/provider.tsx | 6 +-- 23 files changed, 252 insertions(+), 145 deletions(-) create mode 100644 src/core/packages/chrome/browser/src/chrome_next/ai_button.ts create mode 100644 src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts rename src/core/packages/chrome/browser/src/{next_global_search.ts => chrome_next/global_search.ts} (100%) rename src/core/packages/chrome/browser/src/{next_header.ts => chrome_next/header.ts} (100%) create mode 100644 src/core/packages/chrome/browser/src/chrome_next/index.ts diff --git a/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx b/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx index 24e5ebfc552d5..97765863b4771 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx @@ -8,20 +8,23 @@ */ import React from 'react'; -import { useAiButton } from './hooks'; +import { HeaderExtension } from '../shared/header_extension'; +import { useAiButtons } from './hooks'; -/** - * Renders the AI button in the Chrome-Next project header. - * The plugin owns the component; Chrome just places it in the slot. - */ export const AiButtonSlot = React.memo(() => { - const node = useAiButton(); + const buttons = useAiButtons(); - if (!node) { + if (buttons.length === 0) { return null; } - return <>{node}; + return ( + <> + {buttons.map((button, index) => ( + + ))} + + ); }); AiButtonSlot.displayName = 'AiButtonSlot'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts index 6aa997cbcd06d..0b3ba150d2b3c 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { useAiButton } from './use_ai_button'; +export { useAiButtons } from './use_ai_button'; export { useProjectNextAppMenu } from './use_project_next_app_menu'; export { useBackButton } from './use_back_button'; export { useReportTopBarHeight } from './use_report_top_bar_height'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts index 46ada8f9ce7cf..3e7dc5341b0ed 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts +++ b/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts @@ -7,17 +7,15 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ReactNode } from 'react'; import { useMemo } from 'react'; +import type { ChromeNextAiButton } from '@kbn/core-chrome-browser'; import { useObservable } from '@kbn/use-observable'; import { useChromeService } from '@kbn/core-chrome-browser-context'; -/** - * Returns the AI button ReactNode set via `chrome.next.aiButton.set()`, - * or `undefined` if not set. Used by the Chrome-Next header. - */ -export function useAiButton(): ReactNode | undefined { +const EMPTY: ChromeNextAiButton[] = []; + +export function useAiButtons(): ChromeNextAiButton[] { const chrome = useChromeService(); - const node$ = useMemo(() => chrome.next.aiButton.get$(), [chrome]); - return useObservable(node$, undefined); + const buttons$ = useMemo(() => chrome.next.aiButton.get$(), [chrome]); + return useObservable(buttons$, EMPTY); } diff --git a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx index 0ed5e9974150a..cc5ec747af9f8 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx +++ b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx @@ -13,7 +13,7 @@ import React, { useMemo } from 'react'; import { useHasLegacyActionMenu } from '../shared/chrome_hooks'; import { AiButtonSlot } from './ai_button_slot'; import { ProjectNextAppMenu } from './app_menu'; -import { useAiButton, useProjectNextAppMenu } from './hooks'; +import { useAiButtons, useProjectNextAppMenu } from './hooks'; const useTrailingStyles = () => { const { euiTheme } = useEuiTheme(); @@ -38,10 +38,10 @@ const useTrailingStyles = () => { export const ProjectNextTrailingActions = React.memo(() => { const appMenuConfig = useProjectNextAppMenu(); const hasLegacyActionMenu = useHasLegacyActionMenu(); - const aiButton = useAiButton(); + const aiButtons = useAiButtons(); const styles = useTrailingStyles(); - const hasTrailingContent = !!appMenuConfig || hasLegacyActionMenu || !!aiButton; + const hasTrailingContent = !!appMenuConfig || hasLegacyActionMenu || aiButtons.length > 0; if (!hasTrailingContent) { return null; diff --git a/src/core/packages/chrome/browser-components/src/shared/header_extension.tsx b/src/core/packages/chrome/browser-components/src/shared/header_extension.tsx index a9124999e1b5d..7f96fed895d36 100644 --- a/src/core/packages/chrome/browser-components/src/shared/header_extension.tsx +++ b/src/core/packages/chrome/browser-components/src/shared/header_extension.tsx @@ -55,7 +55,7 @@ export const HeaderExtension = ({ extension, display, containerClassName }: Prop if (!isMountPoint(extension)) { return ( -
+
{extension}
diff --git a/src/core/packages/chrome/browser-internal-types/index.ts b/src/core/packages/chrome/browser-internal-types/index.ts index 12664356cf6b5..2e1a79cfa3cf0 100644 --- a/src/core/packages/chrome/browser-internal-types/index.ts +++ b/src/core/packages/chrome/browser-internal-types/index.ts @@ -15,6 +15,8 @@ import type { ChromeBadge, ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension, + ChromeNext, + ChromeNextAiButton, ChromeNextHeaderConfig, ChromeNextGlobalSearchConfig, ChromeProjectNavigationNode, @@ -108,15 +110,18 @@ export interface InternalChromeStart extends ChromeStart { }; /** @internal Extends public `next` with `get$` for Chrome layout components. */ - next: ChromeStart['next'] & { - header: ChromeStart['next']['header'] & { - get$(): Observable; - }; - aiButton: ChromeStart['next']['aiButton'] & { - get$(): Observable; - }; - globalSearch: ChromeStart['next']['globalSearch'] & { - get$(): Observable; - }; + next: InternalChromeNext; +} + +/** @internal */ +export interface InternalChromeNext extends ChromeNext { + header: ChromeNext['header'] & { + get$(): Observable; + }; + aiButton: ChromeNext['aiButton'] & { + get$(): Observable; + }; + globalSearch: ChromeNext['globalSearch'] & { + get$(): Observable; }; } diff --git a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx index d2395dc7b679d..4ae913247582c 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx @@ -9,6 +9,7 @@ import React, { type ReactNode } from 'react'; import { distinctUntilChanged, map, shareReplay } from 'rxjs'; +import type { ChromeNextAiButton } from '@kbn/core-chrome-browser'; import type { RecentlyAccessedService } from '@kbn/recently-accessed'; import { SidebarServiceProvider } from '@kbn/core-chrome-sidebar-context'; import { ChromeServiceProvider } from '@kbn/core-chrome-browser-context'; @@ -176,8 +177,17 @@ export function createChromeApi({ state, services, sidebar }: ChromeApiDeps): In set: services.nextHeader.set, }, aiButton: { - get$: () => state.aiButton.$, - set: state.aiButton.set, + get$: () => state.aiButton.$.pipe(map((buttons) => [...buttons])), + register: (button: ChromeNextAiButton) => { + state.aiButton.update((prev) => new Set([...prev, button])); + return () => { + state.aiButton.update((prev) => { + const next = new Set(prev); + next.delete(button); + return next; + }); + }; + }, }, globalSearch: { get$: () => state.globalSearch.$, diff --git a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts index a4188415c0966..5bfd3c98549f1 100644 --- a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts +++ b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts @@ -19,6 +19,7 @@ import type { ChromeGlobalHelpExtensionMenuLink, ChromeHelpExtension, ChromeNavLink, + ChromeNextAiButton, ChromeNextGlobalSearchConfig, ChromeUserBanner, } from '@kbn/core-chrome-browser'; @@ -65,7 +66,7 @@ export interface ChromeState { globalFooter: State; customNavLink: State; appMenu: State; - aiButton: State; + aiButton: State>; globalSearch: State; /** Help system */ @@ -110,7 +111,7 @@ export function createChromeState({ application, docLinks }: ChromeStateDeps): C // UI Elements (not reset on app change) const globalFooter = createState(null); - const aiButton = createState(undefined); + const aiButton = createState>(new Set()); const globalSearch = createState(undefined); const customNavLink = createState(undefined); diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index 125195446c50c..83002c8578240 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -116,8 +116,8 @@ const createStartContractMock = () => { }), }), aiButton: lazyObject({ - get$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), - set: jest.fn(), + get$: jest.fn().mockReturnValue(new BehaviorSubject([])), + register: jest.fn().mockReturnValue(() => {}), }), globalSearch: lazyObject({ get$: jest.fn().mockReturnValue(nextGlobalSearchState$), diff --git a/src/core/packages/chrome/browser/index.ts b/src/core/packages/chrome/browser/index.ts index 4aa887a61adc3..a4da1b3fe2bd6 100644 --- a/src/core/packages/chrome/browser/index.ts +++ b/src/core/packages/chrome/browser/index.ts @@ -55,12 +55,14 @@ export type { SidebarAppDefinition, SidebarSetup, SidebarStart, + ChromeNext, + ChromeNextAiButton, + ChromeNextGlobalSearchConfig, ChromeNextHeaderBack, ChromeNextHeaderBadge, + ChromeNextHeaderCallout, ChromeNextHeaderConfig, - ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderGlobalActions, + ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderTab, - ChromeNextHeaderCallout, - ChromeNextGlobalSearchConfig, } from './src'; diff --git a/src/core/packages/chrome/browser/src/chrome_next/ai_button.ts b/src/core/packages/chrome/browser/src/chrome_next/ai_button.ts new file mode 100644 index 0000000000000..1d5f4a1114425 --- /dev/null +++ b/src/core/packages/chrome/browser/src/chrome_next/ai_button.ts @@ -0,0 +1,15 @@ +/* + * 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 type { ChromeExtensionContent } from '@kbn/core-mount-utils-browser'; + +/** @public */ +export interface ChromeNextAiButton { + content: ChromeExtensionContent; +} diff --git a/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts new file mode 100644 index 0000000000000..71f71082837b0 --- /dev/null +++ b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts @@ -0,0 +1,47 @@ +/* + * 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 type { ChromeNextAiButton } from './ai_button'; +import type { ChromeNextGlobalSearchConfig } from './global_search'; +import type { ChromeNextHeaderConfig } from './header'; + +/** + * Chrome-Next APIs: header configuration, AI button slot, global search, and future slots. + * @public + */ +export interface ChromeNext { + header: { + /** + * Set the Chrome-Next header configuration for the current page. + * Chrome renders the title, metadata, global actions, and app menu. + * + * Pass `undefined` to clear (e.g. on unmount or route change). + * Automatically cleared on app change. + */ + set(config?: ChromeNextHeaderConfig): void; + }; + aiButton: { + /** + * Register an AI button for the Chrome-Next header. + * Multiple plugins can register buttons; each owns its own visibility logic. + * Chrome renders all registered buttons sorted by `order` in a fixed slot + * at the far right of the header. + * Returns an unregister callback. Global — persists across app changes. + */ + register(button: ChromeNextAiButton): () => void; + }; + globalSearch: { + /** + * Set the global search configuration for the Chrome-Next sidenav. + * Chrome renders a search icon button in the sidenav header items; clicking it fires `onClick`. + * Pass `undefined` to remove the button. Global — persists across app changes. + */ + set(config?: ChromeNextGlobalSearchConfig): void; + }; +} diff --git a/src/core/packages/chrome/browser/src/next_global_search.ts b/src/core/packages/chrome/browser/src/chrome_next/global_search.ts similarity index 100% rename from src/core/packages/chrome/browser/src/next_global_search.ts rename to src/core/packages/chrome/browser/src/chrome_next/global_search.ts diff --git a/src/core/packages/chrome/browser/src/next_header.ts b/src/core/packages/chrome/browser/src/chrome_next/header.ts similarity index 100% rename from src/core/packages/chrome/browser/src/next_header.ts rename to src/core/packages/chrome/browser/src/chrome_next/header.ts diff --git a/src/core/packages/chrome/browser/src/chrome_next/index.ts b/src/core/packages/chrome/browser/src/chrome_next/index.ts new file mode 100644 index 0000000000000..eabbba32385aa --- /dev/null +++ b/src/core/packages/chrome/browser/src/chrome_next/index.ts @@ -0,0 +1,21 @@ +/* + * 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". + */ + +export type { ChromeNextAiButton } from './ai_button'; +export type { ChromeNext } from './chrome_next'; +export type { ChromeNextGlobalSearchConfig } from './global_search'; +export type { + ChromeNextHeaderBack, + ChromeNextHeaderBadge, + ChromeNextHeaderCallout, + ChromeNextHeaderConfig, + ChromeNextHeaderGlobalActions, + ChromeNextHeaderMetadataSlotItem, + ChromeNextHeaderTab, +} from './header'; diff --git a/src/core/packages/chrome/browser/src/contracts.ts b/src/core/packages/chrome/browser/src/contracts.ts index a22be6f9dd22c..0d869c8843a53 100644 --- a/src/core/packages/chrome/browser/src/contracts.ts +++ b/src/core/packages/chrome/browser/src/contracts.ts @@ -10,8 +10,7 @@ import type { ReactNode } from 'react'; import type { Observable } from 'rxjs'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; -import type { ChromeNextHeaderConfig } from './next_header'; -import type { ChromeNextGlobalSearchConfig } from './next_global_search'; +import type { ChromeNext } from './chrome_next/chrome_next'; import type { ChromeNavLink, ChromeNavLinks } from './nav_links'; import type { ChromeRecentlyAccessed } from './recently_accessed'; import type { ChromeDocTitle } from './doc_title'; @@ -324,38 +323,8 @@ export interface ChromeStart { */ getActiveSolutionNavId(): SolutionId | null; - /** - * Chrome-Next APIs: header configuration, AI button slot, and future slots. - */ - next: { - header: { - /** - * Set the Chrome-Next header configuration for the current page. - * Chrome renders the title, metadata, global actions, and app menu. - * - * Pass `undefined` to clear (e.g. on unmount or route change). - * Automatically cleared on app change. - */ - set(config?: ChromeNextHeaderConfig): void; - }; - aiButton: { - /** - * Set the AI button component for the Chrome-Next header. - * The plugin owns the full component (rendering, state, tooltips, shortcuts). - * Chrome renders it in a fixed slot at the far right of the header. - * Pass `undefined` to remove. Global — persists across app changes. - */ - set(node?: ReactNode): void; - }; - globalSearch: { - /** - * Set the global search configuration for the Chrome-Next sidenav. - * Chrome renders a search icon button in the sidenav header items; clicking it fires `onClick`. - * Pass `undefined` to remove the button. Global — persists across app changes. - */ - set(config?: ChromeNextGlobalSearchConfig): void; - }; - }; + /** {@inheritdoc ChromeNext} */ + next: ChromeNext; /** * Used only by the rendering service and KibanaRenderingContextProvider to wrap the rendering tree in the Chrome context providers diff --git a/src/core/packages/chrome/browser/src/index.ts b/src/core/packages/chrome/browser/src/index.ts index b2e9c452da7a7..8c62dcc0a8ee3 100644 --- a/src/core/packages/chrome/browser/src/index.ts +++ b/src/core/packages/chrome/browser/src/index.ts @@ -61,13 +61,14 @@ export type { } from './sidebar'; export type { + ChromeNext, + ChromeNextAiButton, + ChromeNextGlobalSearchConfig, ChromeNextHeaderBack, ChromeNextHeaderBadge, + ChromeNextHeaderCallout, ChromeNextHeaderConfig, - ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderGlobalActions, + ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderTab, - ChromeNextHeaderCallout, -} from './next_header'; - -export type { ChromeNextGlobalSearchConfig } from './next_global_search'; +} from './chrome_next'; diff --git a/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx b/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx index 0ce287c31baeb..d96a61aa1675d 100644 --- a/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx +++ b/src/platform/plugins/shared/ai_assistant_management/selection/public/plugin.tsx @@ -225,30 +225,38 @@ export class AIAssistantManagementPlugin isUntouchedUiSetting && (isObservabilityAIAssistantEnabled || isSecurityAIAssistantEnabled || isAiAgentsEnabled) ) { + const mountAiPicker = (element: HTMLElement) => { + ReactDOM.render( + coreStart.rendering.addContext( + + openChatSubject.next(selection) + } + spaces={spaces} + /> + ), + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; + }; + coreStart.chrome.navControls.registerRight({ - mount: (element) => { - ReactDOM.render( - coreStart.rendering.addContext( - - openChatSubject.next(selection) - } - spaces={spaces} - /> - ), - element - ); - - return () => { - ReactDOM.unmountComponentAtNode(element); - }; - }, - // before the user profile + mount: mountAiPicker, order: 1001, }); + + // TODO: Chrome-Next hack — dual registration needed because Chrome Next doesn't render + // HeaderNavControls (registerRight mount points). Remove the registerRight call once + // Chrome Next is the only chrome. See https://github.com/elastic/kibana/issues/260010 + coreStart.chrome.next.aiButton.register({ + content: mountAiPicker, + }); } } diff --git a/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx b/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx index 8501c505b1311..b74a039b19533 100644 --- a/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx +++ b/x-pack/platform/plugins/shared/agent_builder/public/plugin.tsx @@ -284,13 +284,18 @@ export class AgentBuilderPlugin order: 1001, }); - core.chrome.next.aiButton.set( - - ); + // TODO: Chrome-Next hack — dual registration needed because Chrome Next doesn't render + // HeaderNavControls (registerRight mount points). Remove the registerRight call once + // Chrome Next is the only chrome. See https://github.com/elastic/kibana/issues/260010 + core.chrome.next.aiButton.register({ + content: ( + + ), + }); } return agentBuilderService; diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/plugin.tsx b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/plugin.tsx index da25d6da7feb5..c8efa6f0cd4a1 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/plugin.tsx +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/public/plugin.tsx @@ -129,26 +129,34 @@ export class ObservabilityAIAssistantAppPlugin const isEnabled = appService.isEnabled(); if (isEnabled) { + const mountObsAiAssistant = (element: HTMLElement) => { + ReactDOM.render( + , + element, + () => {} + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; + }; + coreStart.chrome.navControls.registerRight({ - mount: (element) => { - ReactDOM.render( - , - element, - () => {} - ); - - return () => { - ReactDOM.unmountComponentAtNode(element); - }; - }, - // right before the user profile + mount: mountObsAiAssistant, order: 1001, }); + + // TODO: Chrome-Next hack — dual registration needed because Chrome Next doesn't render + // HeaderNavControls (registerRight mount points). Remove the registerRight call once + // Chrome Next is the only chrome. See https://github.com/elastic/kibana/issues/260010 + coreStart.chrome.next.aiButton.register({ + content: mountObsAiAssistant, + }); } const service = pluginsStart.observabilityAIAssistant.service; diff --git a/x-pack/solutions/search/plugins/search_assistant/public/plugin.tsx b/x-pack/solutions/search/plugins/search_assistant/public/plugin.tsx index 2d251fed3994d..5715ddf727ac2 100644 --- a/x-pack/solutions/search/plugins/search_assistant/public/plugin.tsx +++ b/x-pack/solutions/search/plugins/search_assistant/public/plugin.tsx @@ -61,26 +61,34 @@ export class SearchAssistantPlugin return {}; } - coreStart.chrome.navControls.registerRight({ - mount: (element) => { - ReactDOM.render( - , - element, - () => {} - ); + const mountSearchAssistant = (element: HTMLElement) => { + ReactDOM.render( + , + element, + () => {} + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; + }; - return () => { - ReactDOM.unmountComponentAtNode(element); - }; - }, - // right before the user profile + coreStart.chrome.navControls.registerRight({ + mount: mountSearchAssistant, order: 1001, }); + // TODO: Chrome-Next hack — dual registration needed because Chrome Next doesn't render + // HeaderNavControls (registerRight mount points). Remove the registerRight call once + // Chrome Next is the only chrome. See https://github.com/elastic/kibana/issues/260010 + coreStart.chrome.next.aiButton.register({ + content: mountSearchAssistant, + }); + return {}; } diff --git a/x-pack/solutions/security/plugins/elastic_assistant/public/plugin.tsx b/x-pack/solutions/security/plugins/elastic_assistant/public/plugin.tsx index d499a13ca3b34..1a3a92de00ed8 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/public/plugin.tsx +++ b/x-pack/solutions/security/plugins/elastic_assistant/public/plugin.tsx @@ -85,6 +85,16 @@ export class ElasticAssistantPublicPlugin }, }); + // TODO: Chrome-Next hack — dual registration needed because Chrome Next doesn't render + // HeaderNavControls (registerRight mount points). Remove the registerRight call once + // Chrome Next is the only chrome. See https://github.com/elastic/kibana/issues/260010 + coreStart.chrome.next.aiButton.register({ + content: (target: HTMLDivElement) => { + const startService = startServices(); + return this.mountAIAssistantButton(target, coreStart, startService); + }, + }); + return {}; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx index f706e7128ea48..74535ab610cef 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/assistant/provider.tsx @@ -56,11 +56,7 @@ export const AssistantProvider: FC> = ({ children }) }, [elasticAssistantSharedState.promptContexts, promptContext, promptContexts]); if (!assistantContextValue) { - // TODO: Chrome-Next quick mitigation — was `return null` which blocked the entire route tree. - // With Chrome Next, the elastic_assistant nav control never mounts (no HeaderNavControls), - // so setAssistantContextValue is never called and this stays undefined. - // See https://github.com/elastic/kibana/issues/260010 - return <>{children}; + return null; } return ( From b9071c38df58ea90827491b3d3a3b44cc3c754b7 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 2 Apr 2026 17:26:55 +0200 Subject: [PATCH 16/77] [ChromeNext] Add a switch to toggle `chrome-next` in devbar (#260952) ## Summary Screenshot 2026-04-02 at 16 58 32 Note: it is possible to make it work without a page reload, but I thought it was not worth the complexity. --- .../src/shared/chrome_hooks.ts | 4 +- .../feature-flags/chrome_next_toggle.tsx | 45 +++++++++++++++++++ .../packages/chrome/feature-flags/index.ts | 17 +++++++ .../developer_toolbar/public/plugin.tsx | 5 +++ .../shared/developer_toolbar/tsconfig.json | 1 + 5 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx diff --git a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts index c0983c11ac22a..a698651d4c304 100644 --- a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts +++ b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts @@ -23,7 +23,7 @@ import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; import { useObservable } from '@kbn/use-observable'; import { useChromeService } from '@kbn/core-chrome-browser-context'; -import { NEXT_CHROME_FEATURE_FLAG_KEY } from '@kbn/core-chrome-feature-flags'; +import { isNextChrome } from '@kbn/core-chrome-feature-flags'; import { useChromeComponentsDeps } from '../context'; /** @@ -253,5 +253,5 @@ export function useNextHeader(): ChromeNextHeaderConfig | undefined { /** Returns whether the next-chrome experience is enabled via feature flag. */ export function useIsNextChrome(): boolean { const { featureFlags } = useChromeComponentsDeps(); - return featureFlags.getBooleanValue(NEXT_CHROME_FEATURE_FLAG_KEY, false); + return isNextChrome(featureFlags); } diff --git a/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx b/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx new file mode 100644 index 0000000000000..ad34f73390274 --- /dev/null +++ b/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx @@ -0,0 +1,45 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; +import { isNextChrome, toggleNextChrome } from './index'; + +const badgeStyles = css` + cursor: pointer; +`; + +interface ChromeNextToggleProps { + featureFlags: FeatureFlagsStart; +} + +export const ChromeNextToggle: React.FC = ({ featureFlags }) => { + const isEnabled = isNextChrome(featureFlags); + + const onClick = useCallback(() => { + toggleNextChrome(featureFlags); + }, [featureFlags]); + + return ( + + + Vibranium: {isEnabled ? 'ON' : 'OFF'} + + + ); +}; diff --git a/src/core/packages/chrome/feature-flags/index.ts b/src/core/packages/chrome/feature-flags/index.ts index 79d7bb5546caf..f41114dffc5e6 100644 --- a/src/core/packages/chrome/feature-flags/index.ts +++ b/src/core/packages/chrome/feature-flags/index.ts @@ -9,8 +9,25 @@ import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; +export { ChromeNextToggle } from './chrome_next_toggle'; + export const NEXT_CHROME_FEATURE_FLAG_KEY = 'core.chrome.next'; +export const NEXT_CHROME_SESSION_STORAGE_KEY = 'dev.core.chrome.next'; export const isNextChrome = (featureFlags: FeatureFlagsStart): boolean => { + try { + const override = sessionStorage.getItem(NEXT_CHROME_SESSION_STORAGE_KEY); + if (override !== null) { + return override === 'true'; + } + } catch { + // sessionStorage may be unavailable + } return featureFlags.getBooleanValue(NEXT_CHROME_FEATURE_FLAG_KEY, false); }; + +export const toggleNextChrome = (featureFlags: FeatureFlagsStart): void => { + const next = !isNextChrome(featureFlags); + sessionStorage.setItem(NEXT_CHROME_SESSION_STORAGE_KEY, String(next)); + window.location.reload(); +}; diff --git a/src/platform/plugins/shared/developer_toolbar/public/plugin.tsx b/src/platform/plugins/shared/developer_toolbar/public/plugin.tsx index bc9d42bc92077..177763590234e 100755 --- a/src/platform/plugins/shared/developer_toolbar/public/plugin.tsx +++ b/src/platform/plugins/shared/developer_toolbar/public/plugin.tsx @@ -14,6 +14,8 @@ import type { InternalChromeStart } from '@kbn/core-chrome-browser-internal-type import { BehaviorSubject } from 'rxjs'; import type { DeveloperToolbarItemProps } from '@kbn/developer-toolbar'; +import { DeveloperToolbarItem } from '@kbn/developer-toolbar'; +import { ChromeNextToggle } from '@kbn/core-chrome-feature-flags'; export type UnregisterItemFn = () => void; export interface DeveloperToolbarItemRegistry { @@ -41,6 +43,9 @@ export class DeveloperToolbarPlugin (core.chrome as InternalChromeStart).setGlobalFooter( + + + ); diff --git a/src/platform/plugins/shared/developer_toolbar/tsconfig.json b/src/platform/plugins/shared/developer_toolbar/tsconfig.json index 3c44c549d013c..4ef9285bdf4b7 100644 --- a/src/platform/plugins/shared/developer_toolbar/tsconfig.json +++ b/src/platform/plugins/shared/developer_toolbar/tsconfig.json @@ -12,5 +12,6 @@ "@kbn/core-chrome-layout-components", "@kbn/config-schema", "@kbn/core-chrome-browser-internal-types", + "@kbn/core-chrome-feature-flags", ] } From bffcd58c24cd83238fdabedee7a3f93ede3eedd7 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 7 Apr 2026 12:50:43 +0200 Subject: [PATCH 17/77] fix types --- .../packages/chrome/feature-flags/chrome_next_toggle.tsx | 2 +- src/core/packages/chrome/feature-flags/index.ts | 6 ++++-- src/platform/plugins/shared/dashboard/moon.yml | 2 +- src/platform/plugins/shared/developer_toolbar/moon.yml | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx b/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx index ad34f73390274..009e643d2fcb8 100644 --- a/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx +++ b/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx @@ -18,7 +18,7 @@ const badgeStyles = css` `; interface ChromeNextToggleProps { - featureFlags: FeatureFlagsStart; + featureFlags: Pick; } export const ChromeNextToggle: React.FC = ({ featureFlags }) => { diff --git a/src/core/packages/chrome/feature-flags/index.ts b/src/core/packages/chrome/feature-flags/index.ts index f41114dffc5e6..fe4fbb3f5dc36 100644 --- a/src/core/packages/chrome/feature-flags/index.ts +++ b/src/core/packages/chrome/feature-flags/index.ts @@ -14,7 +14,9 @@ export { ChromeNextToggle } from './chrome_next_toggle'; export const NEXT_CHROME_FEATURE_FLAG_KEY = 'core.chrome.next'; export const NEXT_CHROME_SESSION_STORAGE_KEY = 'dev.core.chrome.next'; -export const isNextChrome = (featureFlags: FeatureFlagsStart): boolean => { +type FeatureFlagsBooleanReader = Pick; + +export const isNextChrome = (featureFlags: FeatureFlagsBooleanReader): boolean => { try { const override = sessionStorage.getItem(NEXT_CHROME_SESSION_STORAGE_KEY); if (override !== null) { @@ -26,7 +28,7 @@ export const isNextChrome = (featureFlags: FeatureFlagsStart): boolean => { return featureFlags.getBooleanValue(NEXT_CHROME_FEATURE_FLAG_KEY, false); }; -export const toggleNextChrome = (featureFlags: FeatureFlagsStart): void => { +export const toggleNextChrome = (featureFlags: FeatureFlagsBooleanReader): void => { const next = !isNextChrome(featureFlags); sessionStorage.setItem(NEXT_CHROME_SESSION_STORAGE_KEY, String(next)); window.location.reload(); diff --git a/src/platform/plugins/shared/dashboard/moon.yml b/src/platform/plugins/shared/dashboard/moon.yml index bd3d3e9eb852e..ad0fa3a194f70 100644 --- a/src/platform/plugins/shared/dashboard/moon.yml +++ b/src/platform/plugins/shared/dashboard/moon.yml @@ -122,10 +122,10 @@ dependsOn: - '@kbn/monaco' - '@kbn/code-editor' - '@kbn/as-code-shared-schemas' - - '@kbn/core-chrome-browser' - '@kbn/core-http-common' - '@kbn/core-http-server-mocks' - '@kbn/core-http-browser' + - '@kbn/core-chrome-browser' tags: - plugin - prod diff --git a/src/platform/plugins/shared/developer_toolbar/moon.yml b/src/platform/plugins/shared/developer_toolbar/moon.yml index 8aeaace0d2fd0..14c1eac8975c2 100644 --- a/src/platform/plugins/shared/developer_toolbar/moon.yml +++ b/src/platform/plugins/shared/developer_toolbar/moon.yml @@ -23,6 +23,7 @@ dependsOn: - '@kbn/core-chrome-layout-components' - '@kbn/config-schema' - '@kbn/core-chrome-browser-internal-types' + - '@kbn/core-chrome-feature-flags' tags: - plugin - prod From 77218bee4fdd424875969653e6f352f5a77d9551 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 7 Apr 2026 15:46:40 +0200 Subject: [PATCH 18/77] [Chrome Next] Sidenav: renderContent, renderPopover, and customContent for ToolItem (#261525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extend the sidenav navigation component's `ToolItem` type to support custom rendering: - `renderContent` — custom trigger content (e.g. avatar, space badge) instead of an icon - `renderPopover` — custom popover body, taking precedence over `sections` - `customContent` on `Popover` — delegates keyboard navigation to children when true - `anchorPosition` on `Popover` — configurable popover anchor direction - `iconType` is now optional when `renderContent` is provided **These are needed to support (following PR)** - Custom icon/badge for user avatar to display user menu - Custom popover for space selector Includes a storybook story demonstrating avatar and space badge tool items. Screenshot 2026-04-07 at 13 10 23 Screenshot 2026-04-07 at 13 10 17 --- .../feature-flags/chrome_next_toggle.tsx | 6 +- .../packaging/react/type_validation.ts | 42 +++- .../navigation/packaging/react/types.ts | 7 +- .../src/__stories__/navigation.stories.tsx | 181 +++++++++++++++++- .../src/components/icon_button/index.tsx | 126 +++++++++--- .../navigation/src/components/navigation.tsx | 30 ++- .../src/components/side_nav/popover.tsx | 57 ++++-- .../src/components/tool_item/index.tsx | 19 +- .../navigation/src/utils/get_has_submenu.ts | 7 +- src/core/packages/chrome/navigation/types.ts | 14 +- 10 files changed, 412 insertions(+), 77 deletions(-) diff --git a/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx b/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx index 009e643d2fcb8..cef138ec73a95 100644 --- a/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx +++ b/src/core/packages/chrome/feature-flags/chrome_next_toggle.tsx @@ -11,7 +11,7 @@ import React, { useCallback } from 'react'; import { EuiBadge, EuiToolTip } from '@elastic/eui'; import { css } from '@emotion/react'; import type { FeatureFlagsStart } from '@kbn/core-feature-flags-browser'; -import { isNextChrome, toggleNextChrome } from './index'; +import { isNextChrome, toggleNextChrome } from '.'; const badgeStyles = css` cursor: pointer; @@ -29,7 +29,9 @@ export const ChromeNextToggle: React.FC = ({ featureFlags }, [featureFlags]); return ( - + & { iconType: string }; -type NormalizedSourceToolItem = Omit & { iconType: string }; + +type ToolItemStrippedFields = 'iconType' | 'renderContent' | 'renderPopover'; + +type NormalizedSourceToolItem = Omit & { + iconType?: string; +}; +type NormalizedPackagedToolItem = Omit & { + iconType?: string; +}; + type NormalizedSourceNavigationStructure = Omit< SourceNavigationStructure, 'footerItems' | 'primaryItems' @@ -76,10 +85,27 @@ type NormalizedSourceToolSlots = Omit & { + headerTools?: NormalizedPackagedToolItem[]; + footerTools?: NormalizedPackagedToolItem[]; +}; const _menuItem: PackagedMenuItem = {} as NormalizedSourceMenuItem; -const _toolItem: PackagedToolItem = {} as NormalizedSourceToolItem; -const _toolSlots: PackagedToolSlots = {} as NormalizedSourceToolSlots; +const _toolItemForward: NormalizedPackagedToolItem = {} as NormalizedSourceToolItem; +const _toolItemReverse: NormalizedSourceToolItem = {} as NormalizedPackagedToolItem; + +type SourceRenderContentParam = Parameters>[0]; +type PackagedRenderContentParam = Parameters>[0]; +const _renderContentParamForward: PackagedRenderContentParam = {} as SourceRenderContentParam; +const _renderContentParamReverse: SourceRenderContentParam = {} as PackagedRenderContentParam; + +type SourceRenderPopoverParam = Parameters>[0]; +type PackagedRenderPopoverParam = Parameters>[0]; +const _renderPopoverParamForward: PackagedRenderPopoverParam = {} as SourceRenderPopoverParam; +const _renderPopoverParamReverse: SourceRenderPopoverParam = {} as PackagedRenderPopoverParam; + +const _toolSlotsForward: NormalizedPackagedToolSlots = {} as NormalizedSourceToolSlots; +const _toolSlotsReverse: NormalizedSourceToolSlots = {} as NormalizedPackagedToolSlots; const _navigationStructure: PackagedNavigationStructure = {} as NormalizedSourceNavigationStructure; // `NavigationProps` validation — suppressed because `MenuItem.iconType` is @@ -92,8 +118,14 @@ void _secondaryMenuItem; void _secondaryMenuSection; void _sideNavLogo; void _menuItem; -void _toolItem; -void _toolSlots; +void _toolItemForward; +void _toolItemReverse; +void _renderContentParamForward; +void _renderContentParamReverse; +void _renderPopoverParamForward; +void _renderPopoverParamReverse; +void _toolSlotsForward; +void _toolSlotsReverse; void _navigationStructure; void _navigationProps; diff --git a/src/core/packages/chrome/navigation/packaging/react/types.ts b/src/core/packages/chrome/navigation/packaging/react/types.ts index 9a14c8ef9bd86..aaf5f25135c4e 100644 --- a/src/core/packages/chrome/navigation/packaging/react/types.ts +++ b/src/core/packages/chrome/navigation/packaging/react/types.ts @@ -76,11 +76,16 @@ export interface MenuItem { /** * A chrome tool control (search, help, etc.) — not a primary navigation destination. + * + * At least one of `iconType` or `renderContent` must be provided. + * When `renderPopover` is present, it takes precedence over `sections`. */ export interface ToolItem { id: string; label: string; - iconType: string; + iconType?: string; + renderContent?: (state: { isCollapsed: boolean }) => ReactNode; + renderPopover?: (closePopover: () => void) => ReactNode; onClick?: () => void; sections?: SecondaryMenuSection[]; badgeType?: BadgeType; diff --git a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx index 5f7cf0a5b5adb..443191998a685 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -9,7 +9,16 @@ import React, { useState } from 'react'; import type { ComponentProps } from 'react'; -import { EuiSkipLink, useEuiTheme } from '@elastic/eui'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLink, + EuiSkipLink, + EuiText, + useEuiTheme, +} from '@elastic/eui'; import type { UseEuiTheme } from '@elastic/eui'; import type { Meta, StoryFn, StoryObj } from '@storybook/react'; import { APP_MAIN_SCROLL_CONTAINER_ID } from '@kbn/core-chrome-layout-constants'; @@ -78,6 +87,151 @@ const CHROME_TOOLS: NonNullable = { ], }; +const SpaceBadge = () => ( + + D + +); + +const SPACES = [ + { id: 'default', name: 'Default', color: '#f04e98', initial: 'D' }, + { id: 'marketing', name: 'Marketing', color: '#00bfb3', initial: 'M' }, + { id: 'engineering', name: 'Engineering', color: '#6092c0', initial: 'E' }, + { id: 'sales', name: 'Sales', color: '#d6bf57', initial: 'S' }, +]; + +const SpacePicker = ({ closePopover }: { closePopover: () => void }) => ( + + + +

Spaces

+
+
+ {SPACES.map((space) => ( + + + + ))} + + + { + e.preventDefault(); + closePopover(); + }} + > + Manage spaces + + +
+); + +const CUSTOM_TOOLS: NonNullable = { + headerTools: [ + { + id: 'spaces', + label: 'Current space', + renderContent: () => , + renderPopover: (closePopover) => , + }, + { id: 'search', label: 'Search', iconType: 'search', onClick: () => {} }, + ], + footerTools: [ + { + id: 'help', + label: 'Help', + iconType: 'question', + sections: [ + { + id: 'help-links', + items: [{ id: 'docs', label: 'Documentation', href: '/docs' }], + }, + ], + }, + { + id: 'user', + label: 'Jane Doe', + renderContent: () => , + sections: [ + { + id: 'account', + items: [ + { id: 'profile', label: 'Profile', href: '/profile' }, + { id: 'preferences', label: 'Preferences', href: '/preferences' }, + ], + }, + { + id: 'session', + items: [{ id: 'logout', label: 'Log out', href: '/logout' }], + }, + ], + }, + ], +}; + const PreventLinkNavigation = (Story: StoryFn) => { usePreventLinkNavigation(); @@ -210,6 +364,31 @@ export const WithManyItems: StoryObj = { render: (args) => , }; +export const WithCustomToolItems: StoryObj = { + name: 'With custom tool items (avatar & space badge)', + decorators: [ + (Story) => { + return ( + <> + + + + ); + }, + ], + args: { + tools: CUSTOM_TOOLS, + logo: { + id: 'observability', + href: LOGO.href, + label: LOGO.label, + iconType: LOGO.iconType, + hideLabel: true, + }, + }, + render: (args) => , +}; + export const WithinLayout: StoryObj = { name: 'Navigation within Layout', render: (args) => , diff --git a/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx b/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx index a2ca9fae5a477..505bd734c1750 100644 --- a/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx @@ -8,7 +8,7 @@ */ import React, { Suspense, forwardRef } from 'react'; -import type { KeyboardEvent, ComponentProps, Ref } from 'react'; +import type { KeyboardEvent, ReactNode, ComponentProps, Ref } from 'react'; import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; import type { EuiButtonIconProps, IconType } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -24,17 +24,33 @@ export interface IconButtonProps extends Omit { badgeType?: BadgeType; hasContent?: boolean; - iconType: IconType; + iconType?: IconType; + isCollapsed?: boolean; isCurrent?: boolean; isHighlighted: boolean; isNew: boolean; label: string; onClick?: () => void; onKeyDown?: (e: KeyboardEvent) => void; + renderContent?: (state: { isCollapsed: boolean }) => ReactNode; } export const IconButton = forwardRef( - ({ badgeType, hasContent, iconType, isCurrent, isHighlighted, isNew, label, ...props }, ref) => { + ( + { + badgeType, + hasContent, + iconType, + isCollapsed, + isCurrent, + isHighlighted, + isNew, + label, + renderContent, + ...props + }, + ref + ) => { const buttonRef = ref as Ref; const { euiTheme } = useEuiTheme(); const { tooltipRef, handleMouseOut } = useTooltip(); @@ -58,31 +74,87 @@ export const IconButton = forwardRef( display: inline-flex; `; - const buttonProps: ComponentProps & { - 'data-highlighted': string; - 'data-menu-item': string; - } = { - 'aria-current': isCurrent ? 'page' : undefined, - 'aria-label': label, - buttonRef, - color: isHighlighted ? 'primary' : 'text', - 'data-highlighted': isHighlighted ? 'true' : 'false', - 'data-menu-item': 'true', - display: isHighlighted ? 'base' : 'empty', - iconType: 'empty', - size: 's', - css: buttonStyles, - ...props, - }; + let item: JSX.Element; - const item = ( -
- }> - - - {isNew && } -
- ); + if (renderContent) { + const shellStyles = css` + display: inline-flex; + align-items: center; + justify-content: center; + height: ${euiTheme.size.xl}; + width: ${euiTheme.size.xl}; + border: none; + border-radius: ${euiTheme.border.radius.small}; + background: ${isHighlighted ? euiTheme.colors.backgroundBasePrimary : 'transparent'}; + cursor: pointer; + padding: 0; + color: ${isHighlighted ? euiTheme.colors.textPrimary : euiTheme.colors.textParagraph}; + position: relative; + overflow: hidden; + &:hover::before { + content: ''; + position: absolute; + z-index: 0; + inset: 0; + background-color: ${euiTheme.components.buttons.backgroundEmptyTextHover}; + pointer-events: none; + } + &:active::before { + content: ''; + position: absolute; + inset: 0; + background-color: ${euiTheme.components.buttons.backgroundEmptyTextActive}; + } + &:focus-visible { + outline: ${euiTheme.focus.width} solid ${euiTheme.focus.color}; + outline-offset: 2px; + } + `; + + item = ( +
+ + {isNew && } +
+ ); + } else { + const euiButtonProps: ComponentProps & { + 'data-highlighted': string; + 'data-menu-item': string; + } = { + 'aria-current': isCurrent ? 'page' : undefined, + 'aria-label': label, + buttonRef, + color: isHighlighted ? 'primary' : 'text', + 'data-highlighted': isHighlighted ? 'true' : 'false', + 'data-menu-item': 'true', + display: isHighlighted ? 'base' : 'empty', + iconType: 'empty', + size: 's', + css: buttonStyles, + ...props, + }; + + item = ( +
+ }> + + + {isNew && } +
+ ); + } if (!hasContent) { const tooltipStyles = css` diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index c0c56fec98be6..c8182b7c88012 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -189,9 +189,11 @@ export const Navigation = ({ {hasHeaderTools && ( {renderToolItems({ + anchorPosition: 'rightDown', items: headerTools, itemComponent: SideNav.HeaderToolbar.Item, isAnyPopoverLocked, + isCollapsed, popoverItemPrefix: popoverHeaderToolPrefix, })} @@ -247,7 +249,7 @@ export const Navigation = ({ const isFirstSubItem = sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; const subItemAriaDescribedBy = isFirstSubItem - ? ids?.popoverNavigationInstructionsId + ? ids.popoverNavigationInstructionsId : undefined; return ( {hasFooterTools && renderToolItems({ + anchorPosition: 'rightUp', items: footerTools, itemComponent: SideNav.FooterToolbar.Item, isAnyPopoverLocked, + isCollapsed, popoverItemPrefix: popoverFooterToolPrefix, })} {collapseToggle && ( @@ -558,7 +562,7 @@ const renderFooterNavItems = ({ const isFirstSubItem = sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; const subItemAriaDescribedBy = isFirstSubItem - ? ids?.popoverNavigationInstructionsId + ? ids.popoverNavigationInstructionsId : undefined; return ( @@ -591,25 +595,31 @@ const renderFooterNavItems = ({ }); interface RenderToolItemsArgs { + anchorPosition?: 'rightUp' | 'rightDown'; isAnyPopoverLocked: boolean; + isCollapsed: boolean; itemComponent: ForwardRefExoticComponent>; items: ToolItem[]; popoverItemPrefix: string; } const renderToolItems = ({ + anchorPosition, isAnyPopoverLocked, + isCollapsed, itemComponent: ItemComponent, items, popoverItemPrefix, }: RenderToolItemsArgs) => items.map((item) => { - const { onClick: itemOnClick, sections, ...itemProps } = item; - + const { onClick: itemOnClick, sections, renderContent, renderPopover, ...itemProps } = item; + const hasPopoverContent = getHasSubmenu(item); return ( { itemOnClick?.(); }} @@ -627,6 +639,10 @@ const renderToolItems = ({ } > {(closePopover, ids) => { + if (renderPopover) { + return renderPopover(closePopover); + } + const firstNonEmptySectionIndex = sections?.findIndex( (section) => section.items.length > 0 ); @@ -638,7 +654,7 @@ const renderToolItems = ({ const isFirstSubItem = sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; const subItemAriaDescribedBy = isFirstSubItem - ? ids?.popoverNavigationInstructionsId + ? ids.popoverNavigationInstructionsId : undefined; return ( diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx index 6336aad9c982b..10e00e24e1b1a 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx @@ -39,16 +39,23 @@ import { useHoverTimeout } from '../../hooks/use_hover_timeout'; import { useScroll } from '../../hooks/use_scroll'; export interface PopoverIds { - popoverNavigationInstructionsId: string; + popoverNavigationInstructionsId: string | undefined; } export type PopoverChildren = | ReactNode - | ((closePopover: () => void, ids?: PopoverIds) => ReactNode); + | ((closePopover: () => void, ids: PopoverIds) => ReactNode); export interface PopoverProps { + anchorPosition?: 'rightUp' | 'rightDown'; children?: PopoverChildren; container?: HTMLElement; + /** + * When `true`, the popover delegates keyboard navigation and focus + * management to its children (e.g. EuiSelectable). Only Escape is + * handled by the popover itself. + */ + customContent?: boolean; hasContent: boolean; isSidePanelOpen: boolean; isAnyPopoverLocked?: boolean; @@ -75,8 +82,10 @@ export interface PopoverProps { * - Escape to move focus back to the trigger. */ export const Popover = ({ + anchorPosition: anchorPositionProp, children, container, + customContent = false, hasContent, isSidePanelOpen, isAnyPopoverLocked = false, @@ -192,6 +201,10 @@ export const Popover = ({ return; } + if (customContent) { + return; + } + if (e.key === 'Tab') { e.preventDefault(); handleClose(); @@ -201,7 +214,7 @@ export const Popover = ({ handleRovingIndex(e); }, - [handleClose] + [customContent, handleClose] ); const handleBlur: FocusEventHandler = useCallback( @@ -293,7 +306,7 @@ export const Popover = ({ )} - -

- {i18n.translate('core.ui.chrome.sideNavigation.popoverNavigationInstructions', { - defaultMessage: - 'You are in the {label} secondary menu dialog. Use Up and Down arrow keys to navigate the menu. Press Escape to exit to the menu trigger.', - values: { - label, - }, - })} -

-
+ {!customContent && ( + +

+ {i18n.translate('core.ui.chrome.sideNavigation.popoverNavigationInstructions', { + defaultMessage: + 'You are in the {label} secondary menu dialog. Use Up and Down arrow keys to navigate the menu. Press Escape to exit to the menu trigger.', + values: { + label, + }, + })} +

+
+ )} {typeof children === 'function' - ? children(handleClose, { popoverNavigationInstructionsId }) + ? children(handleClose, { + popoverNavigationInstructionsId: customContent + ? undefined + : popoverNavigationInstructionsId, + }) : children}
diff --git a/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx b/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx index ec4b27dbf0a21..93cc6bd881079 100644 --- a/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx @@ -17,20 +17,19 @@ import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; export interface ToolItemProps extends Omit, - Omit { + Omit { hasContent?: boolean; + isCollapsed?: boolean; isHighlighted: boolean; isNew: boolean; onClick?: () => void; onKeyDown?: (e: KeyboardEvent) => void; } -export const ToolItem = forwardRef( - ({ id, ...props }, ref) => ( - } - data-test-subj={`${NAVIGATION_SELECTOR_PREFIX}-toolItem-${id}`} - {...props} - /> - ) -); +export const ToolItem = forwardRef(({ id, ...props }, ref) => ( + } + data-test-subj={`${NAVIGATION_SELECTOR_PREFIX}-toolItem-${id}`} + {...props} + /> +)); diff --git a/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts b/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts index 70ac2e41938ec..ccceda286ab06 100644 --- a/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts +++ b/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts @@ -13,6 +13,9 @@ * @param item - the menu item to check. * @returns `true` if the menu item has a submenu, `false` otherwise. */ -export const getHasSubmenu = (item: { sections?: ReadonlyArray }): boolean => { - return !!item.sections && item.sections.length > 0; +export const getHasSubmenu = (item: { + sections?: ReadonlyArray; + renderPopover?: unknown; +}): boolean => { + return (!!item.sections && item.sections.length > 0) || !!item.renderPopover; }; diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index 690fd4f73f9b5..b74f35aa80db5 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { ReactNode } from 'react'; import type { IconType } from '@elastic/eui'; export type BadgeType = 'beta' | 'techPreview' | 'new'; @@ -99,15 +100,20 @@ export interface MenuItem { /** * A chrome tool control (search, help, profile, etc.) — not a navigation destination. * Tool items can perform an action via `onClick` or expose submenu content via `sections`. + * + * At least one of `iconType` or `renderContent` must be provided. + * When both are present, `renderContent` takes precedence for the trigger visual. + * + * When `renderPopover` is present, it takes precedence over `sections` for the + * popover body content. They are not combined. */ export interface ToolItem { id: string; label: string; - iconType: IconType; + iconType?: IconType; + renderContent?: (state: { isCollapsed: boolean }) => ReactNode; + renderPopover?: (closePopover: () => void) => ReactNode; onClick?: () => void; - /** - * Optional submenu content shown for the tool control. - */ sections?: SecondaryMenuSection[]; badgeType?: BadgeType; 'data-test-subj'?: string; From dcf13a50e84cfc8737cc1438ef97bff8f4e68c8b Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 8 Apr 2026 11:16:05 +0200 Subject: [PATCH 19/77] [FeatureBranch/DoNotReview][Chrome Next] Add a user menu and space selector to the sidenav (#261572) ## Summary Wire the user menu and space selector into the Chrome-Next sidenav via new `chrome.next.userMenu` and `chrome.next.spaceSelector` APIs. - Security plugin provides user avatar, profile link, custom menu links, and logout via `chrome.next.userMenu` - Spaces plugin provides space avatar and selector popover via `chrome.next.spaceSelector` - Add `popoverWidth` to `ToolItem` so custom popovers can override the default 248px width (space selector uses 360px) Screenshot 2026-04-07 at 16 00 57 Screenshot 2026-04-07 at 16 01 01 --- packages/kbn-optimizer/limits.yml | 2 +- .../project/sidenav/navigation/navigation.tsx | 11 ++- .../navigation/to_chrome_tool_slots.ts | 94 +++++++++++++++--- .../src/shared/header_help_menu.tsx | 3 - .../chrome/browser-internal-types/index.ts | 8 ++ .../browser-internal/src/chrome_api.tsx | 8 ++ .../src/state/chrome_state.ts | 8 ++ .../browser-mocks/src/chrome_service.mock.ts | 18 ++++ src/core/packages/chrome/browser/index.ts | 3 + .../browser/src/chrome_next/chrome_next.ts | 20 +++- .../chrome/browser/src/chrome_next/index.ts | 2 + .../browser/src/chrome_next/space_selector.ts | 16 +++ .../browser/src/chrome_next/user_menu.ts | 24 +++++ src/core/packages/chrome/browser/src/index.ts | 3 + .../navigation/packaging/react/types.ts | 1 + .../src/__stories__/navigation.stories.tsx | 1 + .../navigation/src/components/navigation.tsx | 3 +- .../src/components/side_nav/popover.tsx | 10 +- src/core/packages/chrome/navigation/types.ts | 1 + .../nav_control/nav_control_service.tsx | 94 ++++++++++++++++++ .../nav_control/components/spaces_menu.tsx | 5 +- .../spaces/public/nav_control/nav_control.tsx | 98 ++++++++++++++++++- 22 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 src/core/packages/chrome/browser/src/chrome_next/space_selector.ts create mode 100644 src/core/packages/chrome/browser/src/chrome_next/user_menu.ts diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index bc69a8ecd2983..065117e51e812 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -42,7 +42,7 @@ pageLoadAssetSize: dataViewManagement: 6250 dataViews: 66000 dataVisualizer: 32727 - developerToolbar: 4467 + developerToolbar: 15000 devTools: 8109 discover: 31186 discoverEnhanced: 5509 diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx index 0bdcd314a5448..3cb66391d61b2 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx @@ -99,10 +99,17 @@ const useNavigationItems = (): NavigationState | null => { const emptyToolSlots: ToolSlots = { headerTools: [], footerTools: [] }; const toolSlots$ = isNextChrome - ? combineLatest([chrome.next.globalSearch.get$(), helpLinks$]).pipe( - map(([searchConfig, helpLinks]) => + ? combineLatest([ + chrome.next.globalSearch.get$(), + chrome.next.spaceSelector.get$(), + chrome.next.userMenu.get$(), + helpLinks$, + ]).pipe( + map(([searchConfig, spaceSelectorConfig, userMenuConfig, helpLinks]) => buildToolSlots({ globalSearch: searchConfig, + spaceSelector: spaceSelectorConfig, + userMenu: userMenuConfig, helpLinks, }) ) diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts index 4d042ec0998ba..ba421a2258a99 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts @@ -8,7 +8,11 @@ */ import { i18n } from '@kbn/i18n'; -import type { ChromeNextGlobalSearchConfig } from '@kbn/core-chrome-browser'; +import type { + ChromeNextGlobalSearchConfig, + ChromeNextSpaceSelectorConfig, + ChromeNextUserMenuConfig, +} from '@kbn/core-chrome-browser'; import type { SecondaryMenuItem, SecondaryMenuSection, @@ -19,34 +23,100 @@ import type { HelpLinks, HelpMenuLinkItem } from '../../../shared/help_menu_link export interface ToolSlotsInput { globalSearch?: ChromeNextGlobalSearchConfig; + spaceSelector?: ChromeNextSpaceSelectorConfig; + userMenu?: ChromeNextUserMenuConfig; helpLinks: HelpLinks; } export const buildToolSlots = (input: ToolSlotsInput): ToolSlots => ({ - headerTools: getHeaderTools(input.globalSearch), - footerTools: getFooterTools(input.helpLinks), + headerTools: getHeaderTools(input.globalSearch, input.spaceSelector), + footerTools: getFooterTools(input.userMenu, input.helpLinks), }); -const getHeaderTools = (globalSearch?: ChromeNextGlobalSearchConfig): ToolItem[] => { - if (!globalSearch) { - return []; +const getHeaderTools = ( + globalSearch?: ChromeNextGlobalSearchConfig, + spaceSelector?: ChromeNextSpaceSelectorConfig +): ToolItem[] => { + const tools: ToolItem[] = []; + + const spaceSelectorItem = getSpaceSelectorToolItem(spaceSelector); + if (spaceSelectorItem) { + tools.push(spaceSelectorItem); } - return [ - { + if (globalSearch) { + tools.push({ id: 'globalSearch', label: i18n.translate('core.chrome.projectSideNav.globalSearchLabel', { defaultMessage: 'Search', }), iconType: 'search', onClick: globalSearch.onClick, - }, - ]; + }); + } + + return tools; }; -const getFooterTools = (helpLinks: HelpLinks): ToolItem[] => { +const getSpaceSelectorToolItem = ( + config: ChromeNextSpaceSelectorConfig | undefined +): ToolItem | undefined => { + if (!config) { + return undefined; + } + + return { + id: 'spaceSelector', + label: config.label, + renderContent: () => config.renderAvatar(), + renderPopover: (closePopover) => config.renderPopover(closePopover), + popoverWidth: 360, + 'data-test-subj': 'sideNavSpaceSelector', + }; +}; + +const getFooterTools = ( + userMenu: ChromeNextUserMenuConfig | undefined, + helpLinks: HelpLinks +): ToolItem[] => { + const tools: ToolItem[] = []; + const userMenuItem = getUserMenuToolItem(userMenu); + if (userMenuItem) { + tools.push(userMenuItem); + } const helpItem = getHelpToolItem(helpLinks); - return helpItem ? [helpItem] : []; + if (helpItem) { + tools.push(helpItem); + } + return tools; +}; + +const getUserMenuToolItem = ( + config: ChromeNextUserMenuConfig | undefined +): ToolItem | undefined => { + if (!config) { + return undefined; + } + + const items: SecondaryMenuItem[] = config.items.map((item) => ({ + id: item.id, + label: item.label, + href: item.href, + isExternal: item.isExternal, + 'data-test-subj': item['data-test-subj'], + })); + + if (items.length === 0) { + return undefined; + } + + return { + id: 'userMenu', + label: config.label, + renderContent: () => config.renderAvatar(), + sections: [{ id: 'userMenuLinks', items }], + 'data-test-subj': 'sideNavUserMenu', + }; }; const getHelpToolItem = (helpLinks: HelpLinks): ToolItem | undefined => { diff --git a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx index 318edf3db91bf..1446bf56cfd96 100644 --- a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx +++ b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx @@ -182,9 +182,6 @@ export const HeaderHelpMenu = () => { id="headerHelpMenu" isOpen={isOpen} repositionOnScroll - aria-label={i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuAriaLabel', { - defaultMessage: 'Help menu', - })} > diff --git a/src/core/packages/chrome/browser-internal-types/index.ts b/src/core/packages/chrome/browser-internal-types/index.ts index 2e1a79cfa3cf0..d76ccac6389cb 100644 --- a/src/core/packages/chrome/browser-internal-types/index.ts +++ b/src/core/packages/chrome/browser-internal-types/index.ts @@ -19,6 +19,8 @@ import type { ChromeNextAiButton, ChromeNextHeaderConfig, ChromeNextGlobalSearchConfig, + ChromeNextSpaceSelectorConfig, + ChromeNextUserMenuConfig, ChromeProjectNavigationNode, ChromeSetProjectBreadcrumbsParams, ChromeUserBanner, @@ -124,4 +126,10 @@ export interface InternalChromeNext extends ChromeNext { globalSearch: ChromeNext['globalSearch'] & { get$(): Observable; }; + userMenu: ChromeNext['userMenu'] & { + get$(): Observable; + }; + spaceSelector: ChromeNext['spaceSelector'] & { + get$(): Observable; + }; } diff --git a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx index 4ae913247582c..8c5740c4b15ae 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx @@ -193,6 +193,14 @@ export function createChromeApi({ state, services, sidebar }: ChromeApiDeps): In get$: () => state.globalSearch.$, set: state.globalSearch.set, }, + userMenu: { + get$: () => state.userMenu.$, + set: state.userMenu.set, + }, + spaceSelector: { + get$: () => state.spaceSelector.$, + set: state.spaceSelector.set, + }, }, sidebar, diff --git a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts index 5bfd3c98549f1..10b5759fe897c 100644 --- a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts +++ b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts @@ -21,6 +21,8 @@ import type { ChromeNavLink, ChromeNextAiButton, ChromeNextGlobalSearchConfig, + ChromeNextSpaceSelectorConfig, + ChromeNextUserMenuConfig, ChromeUserBanner, } from '@kbn/core-chrome-browser'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; @@ -68,6 +70,8 @@ export interface ChromeState { appMenu: State; aiButton: State>; globalSearch: State; + userMenu: State; + spaceSelector: State; /** Help system */ help: { @@ -113,6 +117,8 @@ export function createChromeState({ application, docLinks }: ChromeStateDeps): C const globalFooter = createState(null); const aiButton = createState>(new Set()); const globalSearch = createState(undefined); + const userMenu = createState(undefined); + const spaceSelector = createState(undefined); const customNavLink = createState(undefined); // Help System @@ -138,6 +144,8 @@ export function createChromeState({ application, docLinks }: ChromeStateDeps): C globalFooter, aiButton, globalSearch, + userMenu, + spaceSelector, customNavLink, appMenu, help: { diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index 83002c8578240..2cde4142f2110 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -14,6 +14,8 @@ import type { ChromeBreadcrumb, ChromeNextHeaderConfig, ChromeNextGlobalSearchConfig, + ChromeNextSpaceSelectorConfig, + ChromeNextUserMenuConfig, } from '@kbn/core-chrome-browser'; import type { InternalChromeSetup, @@ -33,6 +35,10 @@ const createStartContractMock = () => { const nextGlobalSearchState$ = new BehaviorSubject( undefined ); + const nextUserMenuState$ = new BehaviorSubject(undefined); + const nextSpaceSelectorState$ = new BehaviorSubject( + undefined + ); const startContract: DeeplyMockedKeys = lazyObject({ withProvider: jest.fn((children) => children), @@ -125,6 +131,18 @@ const createStartContractMock = () => { nextGlobalSearchState$.next(config); }), }), + userMenu: lazyObject({ + get$: jest.fn().mockReturnValue(nextUserMenuState$), + set: jest.fn((config?: ChromeNextUserMenuConfig) => { + nextUserMenuState$.next(config); + }), + }), + spaceSelector: lazyObject({ + get$: jest.fn().mockReturnValue(nextSpaceSelectorState$), + set: jest.fn((config?: ChromeNextSpaceSelectorConfig) => { + nextSpaceSelectorState$.next(config); + }), + }), }), setGlobalFooter: jest.fn(), getGlobalFooter$: jest.fn().mockReturnValue(new BehaviorSubject(null)), diff --git a/src/core/packages/chrome/browser/index.ts b/src/core/packages/chrome/browser/index.ts index a4da1b3fe2bd6..580130955782d 100644 --- a/src/core/packages/chrome/browser/index.ts +++ b/src/core/packages/chrome/browser/index.ts @@ -65,4 +65,7 @@ export type { ChromeNextHeaderGlobalActions, ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderTab, + ChromeNextSpaceSelectorConfig, + ChromeNextUserMenuConfig, + ChromeNextUserMenuItem, } from './src'; diff --git a/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts index 71f71082837b0..63c935fe3b070 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts @@ -10,9 +10,11 @@ import type { ChromeNextAiButton } from './ai_button'; import type { ChromeNextGlobalSearchConfig } from './global_search'; import type { ChromeNextHeaderConfig } from './header'; +import type { ChromeNextSpaceSelectorConfig } from './space_selector'; +import type { ChromeNextUserMenuConfig } from './user_menu'; /** - * Chrome-Next APIs: header configuration, AI button slot, global search, and future slots. + * Chrome-Next APIs: header configuration, AI button slot, global search, user menu, and space selector. * @public */ export interface ChromeNext { @@ -44,4 +46,20 @@ export interface ChromeNext { */ set(config?: ChromeNextGlobalSearchConfig): void; }; + userMenu: { + /** + * Set the user menu configuration for the Chrome-Next sidenav. + * Chrome renders a user avatar in the sidenav footer with a popover listing the provided items. + * Pass `undefined` to remove. Global — persists across app changes. + */ + set(config?: ChromeNextUserMenuConfig): void; + }; + spaceSelector: { + /** + * Set the space selector configuration for the Chrome-Next sidenav. + * Chrome renders a space avatar in the sidenav header with a custom popover. + * Pass `undefined` to remove. Global — persists across app changes. + */ + set(config?: ChromeNextSpaceSelectorConfig): void; + }; } diff --git a/src/core/packages/chrome/browser/src/chrome_next/index.ts b/src/core/packages/chrome/browser/src/chrome_next/index.ts index eabbba32385aa..7968c144add6b 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/index.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/index.ts @@ -19,3 +19,5 @@ export type { ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderTab, } from './header'; +export type { ChromeNextSpaceSelectorConfig } from './space_selector'; +export type { ChromeNextUserMenuConfig, ChromeNextUserMenuItem } from './user_menu'; diff --git a/src/core/packages/chrome/browser/src/chrome_next/space_selector.ts b/src/core/packages/chrome/browser/src/chrome_next/space_selector.ts new file mode 100644 index 0000000000000..cdfee763c91c7 --- /dev/null +++ b/src/core/packages/chrome/browser/src/chrome_next/space_selector.ts @@ -0,0 +1,16 @@ +/* + * 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 type { ReactNode } from 'react'; + +export interface ChromeNextSpaceSelectorConfig { + label: string; + renderAvatar: () => ReactNode; + renderPopover: (closePopover: () => void) => ReactNode; +} diff --git a/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts b/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts new file mode 100644 index 0000000000000..56e163cfca722 --- /dev/null +++ b/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts @@ -0,0 +1,24 @@ +/* + * 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 type { ReactNode } from 'react'; + +export interface ChromeNextUserMenuConfig { + label: string; + renderAvatar: () => ReactNode; + items: ChromeNextUserMenuItem[]; +} + +export interface ChromeNextUserMenuItem { + id: string; + label: string; + href: string; + isExternal?: boolean; + 'data-test-subj'?: string; +} diff --git a/src/core/packages/chrome/browser/src/index.ts b/src/core/packages/chrome/browser/src/index.ts index 8c62dcc0a8ee3..a59c61945a15e 100644 --- a/src/core/packages/chrome/browser/src/index.ts +++ b/src/core/packages/chrome/browser/src/index.ts @@ -71,4 +71,7 @@ export type { ChromeNextHeaderGlobalActions, ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderTab, + ChromeNextSpaceSelectorConfig, + ChromeNextUserMenuConfig, + ChromeNextUserMenuItem, } from './chrome_next'; diff --git a/src/core/packages/chrome/navigation/packaging/react/types.ts b/src/core/packages/chrome/navigation/packaging/react/types.ts index aaf5f25135c4e..e0bcb0af82a12 100644 --- a/src/core/packages/chrome/navigation/packaging/react/types.ts +++ b/src/core/packages/chrome/navigation/packaging/react/types.ts @@ -88,6 +88,7 @@ export interface ToolItem { renderPopover?: (closePopover: () => void) => ReactNode; onClick?: () => void; sections?: SecondaryMenuSection[]; + popoverWidth?: number | string; badgeType?: BadgeType; 'data-test-subj'?: string; } diff --git a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx index 443191998a685..4f47f883ebb5a 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -196,6 +196,7 @@ const CUSTOM_TOOLS: NonNullable = { label: 'Current space', renderContent: () => , renderPopover: (closePopover) => , + popoverWidth: 360, }, { id: 'search', label: 'Search', iconType: 'search', onClick: () => {} }, ], diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index c8182b7c88012..a3cd3e31b35c9 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -612,7 +612,7 @@ const renderToolItems = ({ popoverItemPrefix, }: RenderToolItemsArgs) => items.map((item) => { - const { onClick: itemOnClick, sections, renderContent, renderPopover, ...itemProps } = item; + const { onClick: itemOnClick, sections, renderContent, renderPopover, popoverWidth, ...itemProps } = item; const hasPopoverContent = getHasSubmenu(item); return ( void; @@ -87,6 +88,7 @@ export const Popover = ({ container, customContent = false, hasContent, + popoverWidth, isSidePanelOpen, isAnyPopoverLocked = false, setIsLocked = () => {}, @@ -274,9 +276,15 @@ export const Popover = ({ width: 100%; `; + const resolvedWidth = popoverWidth + ? typeof popoverWidth === 'number' + ? `${popoverWidth}px` + : popoverWidth + : `${SIDE_PANEL_WIDTH}px`; + const popoverContentStyles = css` --popover-max-height: 37.5rem; - width: ${SIDE_PANEL_WIDTH}px; + width: ${resolvedWidth}; max-height: var(--popover-max-height); ${scrollStyles}; `; diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index b74f35aa80db5..d6ee6bcff2b84 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -115,6 +115,7 @@ export interface ToolItem { renderPopover?: (closePopover: () => void) => ReactNode; onClick?: () => void; sections?: SecondaryMenuSection[]; + popoverWidth?: number | string; badgeType?: BadgeType; 'data-test-subj'?: string; } diff --git a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx index d7a7f72fa124d..e4d84f8f7b42e 100644 --- a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx @@ -12,6 +12,8 @@ import type { Subscription } from 'rxjs'; import { BehaviorSubject, map, ReplaySubject, takeUntil } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; +import type { ChromeNextUserMenuItem } from '@kbn/core-chrome-browser'; +import { i18n } from '@kbn/i18n'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { AuthenticationServiceSetup, @@ -19,9 +21,11 @@ import type { UserMenuLink, } from '@kbn/security-plugin-types-public'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import { UserAvatar, type UserProfileAvatarData } from '@kbn/user-profile-components'; import { SecurityNavControl } from './nav_control_component'; import type { SecurityLicense } from '../../common'; +import { getUserDisplayName, isUserAnonymous } from '../../common/model'; import type { SecurityApiClients } from '../components'; import { AuthenticationProvider, SecurityApiClientsProvider } from '../components'; @@ -118,9 +122,99 @@ export class SecurityNavControlService { ), }); + this.registerChromeNextUserMenu(core, authc); + this.navControlRegistered = true; } + private registerChromeNextUserMenu(core: CoreStart, authc: AuthenticationServiceSetup) { + const editProfileUrl = core.http.basePath.prepend('/security/account'); + const { userProfiles } = this.securityApiClients; + + Promise.all([ + authc.getCurrentUser(), + userProfiles + .getCurrent<{ avatar: UserProfileAvatarData }>({ dataPath: 'avatar' }) + .catch(() => null), + ]) + .then(([currentUser, userProfile]) => { + const userDisplayName = getUserDisplayName(currentUser); + const isAnonymous = isUserAnonymous(currentUser); + + const renderAvatar = () => + userProfile ? ( + + ) : ( + + ); + + const buildItems = (userMenuLinks: UserMenuLink[]): ChromeNextUserMenuItem[] => { + const items: ChromeNextUserMenuItem[] = []; + + const sorted = this.sortUserMenuLinks(userMenuLinks); + const hasCustomProfileLinks = sorted.some(({ setAsProfile }) => setAsProfile === true); + + if (!isAnonymous && !hasCustomProfileLinks) { + items.push({ + id: 'profileLink', + label: i18n.translate('xpack.security.navControlComponent.editProfileLinkText', { + defaultMessage: 'Edit profile', + }), + href: editProfileUrl, + 'data-test-subj': 'profileLink', + }); + } + + for (const link of sorted) { + if (link.content || !link.label || !link.href) { + continue; + } + items.push({ + id: `userMenuLink__${link.label}`, + label: link.label, + href: link.href, + 'data-test-subj': `userMenuLink__${link.label}`, + }); + } + + items.push({ + id: 'logoutLink', + label: isAnonymous + ? i18n.translate('xpack.security.navControlComponent.loginLinkText', { + defaultMessage: 'Log in', + }) + : i18n.translate('xpack.security.navControlComponent.logoutLinkText', { + defaultMessage: 'Log out', + }), + href: this.logoutUrl, + 'data-test-subj': 'logoutLink', + }); + + return items; + }; + + const setConfig = (userMenuLinks: UserMenuLink[]) => { + core.chrome.next.userMenu.set({ + label: userDisplayName, + renderAvatar, + items: buildItems(userMenuLinks), + }); + }; + + setConfig(this.userMenuLinks$.value); + + this.userMenuLinks$.pipe(takeUntil(this.stop$)).subscribe((links) => setConfig(links)); + }) + .catch(() => { + // Chrome Next user menu unavailable — legacy nav control still active + }); + } + private sortUserMenuLinks(userMenuLinks: UserMenuLink[]) { return sortBy(userMenuLinks, 'order'); } diff --git a/x-pack/platform/plugins/shared/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/platform/plugins/shared/spaces/public/nav_control/components/spaces_menu.tsx index 32026202e7c50..5ada455bfcd8b 100644 --- a/x-pack/platform/plugins/shared/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/platform/plugins/shared/spaces/public/nav_control/components/spaces_menu.tsx @@ -54,6 +54,7 @@ interface Props { allowSolutionVisibility: boolean; eventTracker: EventTracker; isLoading: boolean; + width?: number | string; } class SpacesMenuUI extends Component { public render() { @@ -107,7 +108,9 @@ class SpacesMenuUI extends Component { options={spaceOptions} singleSelection={'always'} css={css` - width: 400px; + width: ${typeof this.props.width === 'number' + ? `${this.props.width}px` + : this.props.width ?? '400px'}; `} onChange={this.spaceSelectionChange} listProps={{ diff --git a/x-pack/platform/plugins/shared/spaces/public/nav_control/nav_control.tsx b/x-pack/platform/plugins/shared/spaces/public/nav_control/nav_control.tsx index 989d62e911e70..cf0e75c36125a 100644 --- a/x-pack/platform/plugins/shared/spaces/public/nav_control/nav_control.tsx +++ b/x-pack/platform/plugins/shared/spaces/public/nav_control/nav_control.tsx @@ -5,16 +5,21 @@ * 2.0. */ -import { EuiSkeletonRectangle } from '@elastic/eui'; +import { EuiSkeletonCircle, EuiSkeletonRectangle } from '@elastic/eui'; import { css } from '@emotion/react'; -import React, { lazy, Suspense } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import type { CoreStart } from '@kbn/core/public'; +import type { ChromeNextSpaceSelectorConfig } from '@kbn/core-chrome-browser'; import { QueryClient, QueryClientProvider } from '@kbn/react-query'; import { euiThemeVars } from '@kbn/ui-theme'; +import { SpacesMenu } from './components/spaces_menu'; +import { useSpaces } from './hooks/use_spaces'; +import type { Space } from '../../common'; import type { EventTracker } from '../analytics'; import type { ConfigType } from '../config'; +import { getSpaceAvatarComponent } from '../space_avatar'; import type { SpacesManager } from '../spaces_manager'; const LazyNavControlPopover = lazy(() => @@ -23,6 +28,10 @@ const LazyNavControlPopover = lazy(() => })) ); +const LazySpaceAvatar = lazy(() => + getSpaceAvatarComponent().then((component) => ({ default: component })) +); + export function initSpacesNavControl( spacesManager: SpacesManager, core: CoreStart, @@ -38,6 +47,17 @@ export function initSpacesNavControl( }, }); + registerHeaderSpacesControl(spacesManager, core, config, eventTracker, queryClient); + registerSidenavSpacesControl(spacesManager, core, config, eventTracker, queryClient); +} + +function registerHeaderSpacesControl( + spacesManager: SpacesManager, + core: CoreStart, + config: ConfigType, + eventTracker: EventTracker, + queryClient: QueryClient +) { const SpacesNavControl = () => { if (core.http.anonymousPaths.isAnonymous(window.location.pathname)) { return null; @@ -77,3 +97,77 @@ export function initSpacesNavControl( content: , }); } + +function registerSidenavSpacesControl( + spacesManager: SpacesManager, + core: CoreStart, + config: ConfigType, + eventTracker: EventTracker, + queryClient: QueryClient +) { + if (core.http.anonymousPaths.isAnonymous(window.location.pathname)) { + return; + } + + const SidenavSpacesPopoverContent = ({ closePopover }: { closePopover: () => void }) => { + const [activeSpace, setActiveSpace] = useState(null); + const { data, isLoading } = useSpaces(spacesManager); + + useEffect(() => { + const sub = spacesManager.onActiveSpaceChange$.subscribe((space) => { + setActiveSpace(space); + }); + return () => sub.unsubscribe(); + }, []); + + const handleToggle = useCallback(() => { + closePopover(); + }, [closePopover]); + + return ( + + ); + }; + + const setConfig = (space: Space | null) => { + if (!space) { + core.chrome.next.spaceSelector.set(undefined); + return; + } + + const selectorConfig: ChromeNextSpaceSelectorConfig = { + label: space.name, + renderAvatar: () => ( + }> + + + ), + renderPopover: (closePopover) => ( + + + + ), + }; + + core.chrome.next.spaceSelector.set(selectorConfig); + }; + + // Subscription intentionally lives for the page lifetime — no teardown needed. + spacesManager.onActiveSpaceChange$.subscribe((space) => { + setConfig(space); + }); +} From 347f37c9365525ee5dfa5d34c989c09550e29030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngeles=20Mart=C3=ADnez=20Barrio?= Date: Wed, 8 Apr 2026 16:36:40 +0200 Subject: [PATCH 20/77] [FeatureBranch/DoNotReview][Chrome Next] Add global search modal (#261952) Closes https://github.com/elastic/kibana/issues/259116 ## Summary This PR adds the global search modal: - Takes the existing global search and moves it into a modal - Sets a width and height to keep the modal from changing size and position as you search - Reuses existing global search functionality (navigate-to-app, navigate-to-saved-object...) - Adds a listener in navigation to be able to open/close the modal with a keyboard shortcut (Cmd+K / Ctrl+K) ### Testing https://github.com/user-attachments/assets/14fa6d4c-29e7-49b0-be6c-68b0b65bbc31 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../navigation/to_chrome_tool_slots.ts | 1 + .../browser/src/chrome_next/global_search.ts | 2 + .../navigation/packaging/react/types.ts | 1 + .../navigation/src/components/navigation.tsx | 20 +- .../src/hooks/use_tool_shortcuts.ts | 50 +++ src/core/packages/chrome/navigation/types.ts | 1 + .../packages/overlays/browser/src/modal.ts | 1 + .../char_limit_exceeded_message.tsx | 38 ++ .../public/components/empty_message.tsx | 29 ++ .../public/components/search_bar.tsx | 326 +++--------------- .../{popover_footer.tsx => search_footer.tsx} | 13 +- .../public/components/search_modal.tsx | 43 +++ .../components/search_modal_internal.tsx | 149 ++++++++ ...placeholder.tsx => search_placeholder.tsx} | 7 +- .../public/components/types.ts | 18 +- .../public/hooks/use_search_state.ts | 288 ++++++++++++++++ .../global_search_bar/public/plugin.tsx | 71 +++- .../private/global_search_bar/tsconfig.json | 3 +- 18 files changed, 752 insertions(+), 309 deletions(-) create mode 100644 src/core/packages/chrome/navigation/src/hooks/use_tool_shortcuts.ts create mode 100644 x-pack/platform/plugins/private/global_search_bar/public/components/char_limit_exceeded_message.tsx create mode 100644 x-pack/platform/plugins/private/global_search_bar/public/components/empty_message.tsx rename x-pack/platform/plugins/private/global_search_bar/public/components/{popover_footer.tsx => search_footer.tsx} (87%) create mode 100644 x-pack/platform/plugins/private/global_search_bar/public/components/search_modal.tsx create mode 100644 x-pack/platform/plugins/private/global_search_bar/public/components/search_modal_internal.tsx rename x-pack/platform/plugins/private/global_search_bar/public/components/{popover_placeholder.tsx => search_placeholder.tsx} (92%) create mode 100644 x-pack/platform/plugins/private/global_search_bar/public/hooks/use_search_state.ts diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts index ba421a2258a99..42b702299c120 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts @@ -52,6 +52,7 @@ const getHeaderTools = ( }), iconType: 'search', onClick: globalSearch.onClick, + shortcutKey: globalSearch.shortcutKey, }); } diff --git a/src/core/packages/chrome/browser/src/chrome_next/global_search.ts b/src/core/packages/chrome/browser/src/chrome_next/global_search.ts index 3ae7cbf66193e..24137cbaa9807 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/global_search.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/global_search.ts @@ -13,4 +13,6 @@ export interface ChromeNextGlobalSearchConfig { /** Called when the search icon button in the sidenav is clicked. */ onClick: () => void; + /** The keyboard shortcut key to trigger the global search modal. */ + shortcutKey?: string; } diff --git a/src/core/packages/chrome/navigation/packaging/react/types.ts b/src/core/packages/chrome/navigation/packaging/react/types.ts index e0bcb0af82a12..7daadb2afa9ca 100644 --- a/src/core/packages/chrome/navigation/packaging/react/types.ts +++ b/src/core/packages/chrome/navigation/packaging/react/types.ts @@ -91,6 +91,7 @@ export interface ToolItem { popoverWidth?: number | string; badgeType?: BadgeType; 'data-test-subj'?: string; + shortcutKey?: string; } /** diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index a3cd3e31b35c9..4b9a1597de1b6 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -44,6 +44,9 @@ import { useNavigation } from '../hooks/use_navigation'; import { useNewItems } from '../hooks/use_new_items'; import { useResponsiveMenu } from '../hooks/use_responsive_menu'; import { getHighContrastSeparator } from '../hooks/use_high_contrast_mode_styles'; +import { useToolShortcuts } from '../hooks/use_tool_shortcuts'; + +const EMPTY_TOOL_ITEMS: ToolItem[] = []; const navigationWrapperStyles = css` display: flex; @@ -160,12 +163,15 @@ export const Navigation = ({ const collapseToggle = onToggleCollapsed && !forcedCollapsed ? onToggleCollapsed : undefined; - const headerTools = tools?.headerTools ?? []; - const footerTools = tools?.footerTools ?? []; + const headerTools = tools?.headerTools ?? EMPTY_TOOL_ITEMS; + const footerTools = tools?.footerTools ?? EMPTY_TOOL_ITEMS; const hasHeaderTools = headerTools.length > 0; const hasFooterTools = footerTools.length > 0; const showFooterToolbar = hasFooterTools || Boolean(collapseToggle); + const allTools = useMemo(() => [...headerTools, ...footerTools], [headerTools, footerTools]); + useToolShortcuts({ tools: allTools }); + const topSectionStyles = useMemo(() => getTopSectionStyles(euiThemeContext), [euiThemeContext]); const footerNavItems = items.footerItems.slice(0, MAX_FOOTER_ITEMS); @@ -612,7 +618,15 @@ const renderToolItems = ({ popoverItemPrefix, }: RenderToolItemsArgs) => items.map((item) => { - const { onClick: itemOnClick, sections, renderContent, renderPopover, popoverWidth, ...itemProps } = item; + const { + onClick: itemOnClick, + sections, + renderContent, + renderPopover, + popoverWidth, + shortcutKey, + ...itemProps + } = item; const hasPopoverContent = getHasSubmenu(item); return ( { + if (typeof navigator === 'undefined') return false; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = (nav.userAgentData?.platform ?? nav.userAgent ?? '').toLowerCase(); + return platform.includes('mac'); +})(); + +export const useToolShortcuts = ({ tools }: UseToolShortcutsProps) => { + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + const toolWithShortcut = tools.find( + (tool) => + tool.shortcutKey && + event.key === tool.shortcutKey && + (isMac ? event.metaKey : event.ctrlKey) + ); + if (toolWithShortcut) { + event.preventDefault(); + toolWithShortcut.onClick?.(); + } + }, + [tools] + ); + + useEffect(() => { + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onKeyDown]); +}; diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index d6ee6bcff2b84..10c9f9e1bad5a 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -118,6 +118,7 @@ export interface ToolItem { popoverWidth?: number | string; badgeType?: BadgeType; 'data-test-subj'?: string; + shortcutKey?: string; } /** diff --git a/src/core/packages/overlays/browser/src/modal.ts b/src/core/packages/overlays/browser/src/modal.ts index 6c134270fda28..ce0b78f0e4223 100644 --- a/src/core/packages/overlays/browser/src/modal.ts +++ b/src/core/packages/overlays/browser/src/modal.ts @@ -68,4 +68,5 @@ export interface OverlayModalOpenOptions { 'data-test-subj'?: string; maxWidth?: boolean | number | string; 'aria-labelledby'?: EuiModalProps['aria-labelledby']; + outsideClickCloses?: boolean; } diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/char_limit_exceeded_message.tsx b/x-pack/platform/plugins/private/global_search_bar/public/components/char_limit_exceeded_message.tsx new file mode 100644 index 0000000000000..eae364868ed88 --- /dev/null +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/char_limit_exceeded_message.tsx @@ -0,0 +1,38 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiText } from '@elastic/eui'; +import React from 'react'; +import { SearchPlaceholder } from './search_placeholder'; + +interface CharLimitExceededMessageProps { + basePathUrl: string; +} + +export const CharLimitExceededMessage = ({ basePathUrl }: CharLimitExceededMessageProps) => { + const charLimitMessage = ( + <> + +

+ +

+
+

+ +

+ + ); + + return ; +}; diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/empty_message.tsx b/x-pack/platform/plugins/private/global_search_bar/public/components/empty_message.tsx new file mode 100644 index 0000000000000..2e309b0d04948 --- /dev/null +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/empty_message.tsx @@ -0,0 +1,29 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +import { EuiFlexGroup } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React from 'react'; + +export const EmptyMessage = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/search_bar.tsx b/x-pack/platform/plugins/private/global_search_bar/public/components/search_bar.tsx index d5431931cec86..cccd304e6853c 100644 --- a/x-pack/platform/plugins/private/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/search_bar.tsx @@ -5,16 +5,11 @@ * 2.0. */ -import type { EuiSelectableTemplateSitewideOption } from '@elastic/eui'; import { EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, EuiFormLabel, EuiHeaderSectionItemButton, EuiIcon, - EuiText, - EuiLoadingSpinner, EuiSelectableTemplateSitewide, euiSelectableTemplateSitewideRenderOptions, useEuiTheme, @@ -22,72 +17,28 @@ import { mathWithUnits, useEuiMinBreakpoint, } from '@elastic/eui'; -import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable'; import { css } from '@emotion/react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { GlobalSearchFindParams, GlobalSearchResult } from '@kbn/global-search-plugin/public'; -import type { FC } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { apm } from '@elastic/apm-rum'; -import useDebounce from 'react-use/lib/useDebounce'; +import React, { useCallback, useRef, useState } from 'react'; import useEvent from 'react-use/lib/useEvent'; -import useMountedState from 'react-use/lib/useMountedState'; import useObservable from 'react-use/lib/useObservable'; -import type { Subscription } from 'rxjs'; import { isMac } from '@kbn/shared-ux-utility'; -import { blurEvent, sort } from '.'; -import { resultToOption, suggestionToOption } from '../lib'; -import { parseSearchParams } from '../search_syntax'; import { i18nStrings } from '../strings'; -import type { SearchSuggestion } from '../suggestions'; -import { getSuggestions } from '../suggestions'; -import { PopoverFooter } from './popover_footer'; -import { PopoverPlaceholder } from './popover_placeholder'; +import { SearchFooter } from './search_footer'; +import { SearchPlaceholder } from './search_placeholder'; +import { useSearchState } from '../hooks/use_search_state'; +import { EmptyMessage } from './empty_message'; +import { CharLimitExceededMessage } from './char_limit_exceeded_message'; +import { blurEvent } from '.'; import type { SearchBarProps } from './types'; -const SearchCharLimitExceededMessage = (props: { basePathUrl: string }) => { - const charLimitMessage = ( - <> - -

- -

-
-

- -

- - ); - - return ( - - ); -}; - -const EmptyMessage = () => ( - - - - - -); - -export const SearchBar: FC = (opts) => { - const { globalSearch, taggingApi, navigateToUrl, reportEvent, chromeStyle$, ...props } = opts; - - const isMounted = useMountedState(); +export const SearchBar = ({ + globalSearch, + taggingApi, + navigateToUrl, + reportEvent, + chromeStyle$, + basePathUrl, +}: SearchBarProps) => { const { euiTheme } = useEuiTheme(); const chromeStyle = useObservable(chromeStyle$); @@ -95,18 +46,32 @@ export const SearchBar: FC = (opts) => { const [isVisible, setIsVisible] = useState(false); const visibilityButtonRef = useRef(null); - // General hooks - const [initialLoad, setInitialLoad] = useState(false); - const [searchValue, setSearchValue] = useState(''); - const [searchRef, setSearchRef] = useState(null); const [buttonRef, setButtonRef] = useState(null); - const searchSubscription = useRef(null); - const [options, setOptions] = useState([]); - const [searchableTypes, setSearchableTypes] = useState([]); const [showAppend, setShowAppend] = useState(true); - const UNKNOWN_TAG_ID = '__unknown__'; - const [isLoading, setIsLoading] = useState(false); - const [searchCharLimitExceeded, setSearchCharLimitExceeded] = useState(false); + + const { + searchValue, + setSearchValue, + options, + isLoading, + searchCharLimitExceeded, + onChange, + setSearchRef, + searchRef, + triggerInitialLoad, + } = useSearchState({ + globalSearch, + taggingApi, + navigateToUrl, + reportEvent, + onResultSelect: () => { + (document.activeElement as HTMLElement).blur(); + if (searchRef.current) { + setSearchValue(''); + searchRef.current.dispatchEvent(blurEvent); + } + }, + }); const styles = css({ [useEuiBreakpoint(['m', 'l'])]: { @@ -116,130 +81,6 @@ export const SearchBar: FC = (opts) => { width: mathWithUnits(euiTheme.size.xxl, (x) => x * 15), }, }); - // Initialize searchableTypes data - useEffect(() => { - if (initialLoad) { - const fetch = async () => { - const types = await globalSearch.getSearchableTypes(); - setSearchableTypes(types); - }; - fetch(); - } - }, [globalSearch, initialLoad]); - - // Whenever searchValue changes, isLoading = true - useEffect(() => { - setIsLoading(true); - }, [searchValue]); - - const loadSuggestions = useCallback( - (term: string) => { - return getSuggestions({ - searchTerm: term, - searchableTypes, - tagCache: taggingApi?.cache, - }); - }, - [taggingApi, searchableTypes] - ); - - const setDecoratedOptions = useCallback( - ( - _options: GlobalSearchResult[], - suggestions: SearchSuggestion[], - searchTagIds: string[] = [] - ) => { - setOptions([ - ...suggestions.map(suggestionToOption), - ..._options.map((option) => - resultToOption( - option, - searchTagIds?.filter((id) => id !== UNKNOWN_TAG_ID) ?? [], - taggingApi?.ui.getTagList - ) - ), - ]); - }, - [setOptions, taggingApi] - ); - - useDebounce( - () => { - if (initialLoad) { - // cancel pending search if not completed yet - if (searchSubscription.current) { - searchSubscription.current.unsubscribe(); - searchSubscription.current = null; - } - - if (searchValue.length > globalSearch.searchCharLimit) { - // setting this will display an error message to the user - setSearchCharLimitExceeded(true); - return; - } else { - setSearchCharLimitExceeded(false); - } - - const suggestions = loadSuggestions(searchValue.toLowerCase()); - - let aggregatedResults: GlobalSearchResult[] = []; - - if (searchValue.length !== 0) { - reportEvent.searchRequest(); - } - - const rawParams = parseSearchParams(searchValue.toLowerCase(), searchableTypes); - let tagIds: string[] | undefined; - if (taggingApi && rawParams.filters.tags) { - tagIds = rawParams.filters.tags.map( - (tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? UNKNOWN_TAG_ID - ); - } else { - tagIds = undefined; - } - const searchParams: GlobalSearchFindParams = { - term: rawParams.term, - types: rawParams.filters.types, - tags: tagIds, - }; - - searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({ - next: ({ results }) => { - if (!isMounted()) { - return; - } - - if (searchValue.length > 0) { - aggregatedResults = [...results, ...aggregatedResults].sort(sort.byScore); - setDecoratedOptions(aggregatedResults, suggestions, searchParams.tags); - return; - } - - // if searchbar is empty, filter to only applications and sort alphabetically - results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); - aggregatedResults = [...results, ...aggregatedResults].sort(sort.byTitle); - setDecoratedOptions(aggregatedResults, suggestions, searchParams.tags); - }, - error: (err) => { - setIsLoading(false); - - // Not doing anything on error right now because it'll either just show the previous - // results or empty results which is basically what we want anyways - apm.captureError(err, { - labels: { - SearchValue: searchValue, - }, - }); - }, - complete: () => { - setIsLoading(false); - }, - }); - } - }, - 350, - [searchValue, loadSuggestions, searchableTypes, initialLoad] - ); const onKeyDown = useCallback( (event: KeyboardEvent) => { @@ -248,8 +89,8 @@ export const SearchBar: FC = (opts) => { reportEvent.shortcutUsed(); if (chromeStyle === 'project' && !isVisible) { visibilityButtonRef.current?.click(); - } else if (searchRef) { - searchRef.focus(); + } else if (searchRef.current) { + searchRef.current.focus(); } else if (buttonRef) { (buttonRef.children[0] as HTMLButtonElement).click(); } @@ -258,81 +99,6 @@ export const SearchBar: FC = (opts) => { [chromeStyle, isVisible, buttonRef, searchRef, reportEvent] ); - const onChange = useCallback( - (selection: EuiSelectableTemplateSitewideOption[], event: EuiSelectableOnChangeEvent) => { - let selectedRank: number | null = null; - const selected = selection.find(({ checked }, rank) => { - const isChecked = checked === 'on'; - if (isChecked) { - selectedRank = rank + 1; - } - return isChecked; - }); - - if (!selected) { - return; - } - - const selectedLabel = selected.label ?? null; - - // @ts-ignore - ts error is "union type is too complex to express" - const { url, type, suggestion } = selected; - - // if the type is a suggestion, we change the query on the input and trigger a new search - // by setting the searchValue (only setting the field value does not trigger a search) - if (type === '__suggestion__') { - setSearchValue(suggestion); - return; - } - - // errors in tracking should not prevent selection behavior - try { - if (type === 'application') { - const key = selected.key ?? 'unknown'; - const application = `${key.toLowerCase().replaceAll(' ', '_')}`; - reportEvent.navigateToApplication({ - application, - searchValue, - selectedLabel, - selectedRank, - }); - } else { - reportEvent.navigateToSavedObject({ - type, - searchValue, - selectedLabel, - selectedRank, - }); - } - } catch (err) { - apm.captureError(err, { - labels: { - SearchValue: searchValue, - }, - }); - // eslint-disable-next-line no-console - console.log('Error trying to track searchbar metrics', err); - } - - if (event.shiftKey) { - window.open(url); - } else if (event.ctrlKey || event.metaKey) { - window.open(url, '_blank'); - } else { - navigateToUrl(url); - } - - (document.activeElement as HTMLElement).blur(); - if (searchRef) { - clearField(); - searchRef.dispatchEvent(blurEvent); - } - }, - [reportEvent, navigateToUrl, searchRef, searchValue] - ); - - const clearField = () => setSearchValue(''); - const keyboardShortcutTooltip = `${i18nStrings.keyboardShortcutTooltip.prefix}: ${ isMac ? i18nStrings.keyboardShortcutTooltip.onMac : i18nStrings.keyboardShortcutTooltip.onNotMac }`; @@ -411,7 +177,7 @@ export const SearchBar: FC = (opts) => { placeholder: i18nStrings.placeholderText, onFocus: () => { reportEvent.searchFocus(); - setInitialLoad(true); + triggerInitialLoad(); setShowAppend(false); }, onBlur: () => { @@ -421,9 +187,11 @@ export const SearchBar: FC = (opts) => { fullWidth: true, append: getAppendForChromeStyle(), }} - errorMessage={searchCharLimitExceeded ? : null} + errorMessage={ + searchCharLimitExceeded ? : null + } emptyMessage={} - noMatchesMessage={} + noMatchesMessage={} popoverProps={{ zIndex: Number(euiTheme.levels.navigation), 'data-test-subj': 'nav-search-popover', @@ -437,7 +205,7 @@ export const SearchBar: FC = (opts) => { } - popoverFooter={} + popoverFooter={} /> ); }; diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/popover_footer.tsx b/x-pack/platform/plugins/private/global_search_bar/public/components/search_footer.tsx similarity index 87% rename from x-pack/platform/plugins/private/global_search_bar/public/components/popover_footer.tsx rename to x-pack/platform/plugins/private/global_search_bar/public/components/search_footer.tsx index 03b603380f49d..899d96b75c626 100644 --- a/x-pack/platform/plugins/private/global_search_bar/public/components/popover_footer.tsx +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/search_footer.tsx @@ -5,13 +5,16 @@ * 2.0. */ -import type { FC } from 'react'; import React from 'react'; import { EuiCode, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { isMac } from '@kbn/shared-ux-utility'; -export const PopoverFooter: FC = () => { +interface SearchFooterProps { + shortcutKey?: string; +} + +export const SearchFooter = ({ shortcutKey = '/' }: SearchFooterProps) => { return ( { {isMac ? ( ) : ( )} diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/search_modal.tsx b/x-pack/platform/plugins/private/global_search_bar/public/components/search_modal.tsx new file mode 100644 index 0000000000000..dbf79ddbae0b3 --- /dev/null +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/search_modal.tsx @@ -0,0 +1,43 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { css, Global } from '@emotion/react'; +import { dynamic } from '@kbn/shared-ux-utility'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { SearchModalProps } from './types'; +import { SEARCH_MODAL_SELECTOR_PREFIX, SEARCH_MODAL_HEIGHT, SEARCH_MODAL_WIDTH } from './types'; + +const modalOverlayStyles = css` + .${SEARCH_MODAL_SELECTOR_PREFIX} { + block-size: ${SEARCH_MODAL_HEIGHT}vh; + inline-size: ${SEARCH_MODAL_WIDTH}px; + + .euiModal__closeIcon { + display: none; + } + } +`; + +const LazySearchModal = dynamic( + () => import('./search_modal_internal').then((mod) => ({ default: mod.SearchModalInternal })), + { + fallback: ( + + + + + + ), + } +); +export const SearchModal = (props: SearchModalProps) => ( + <> + + + +); diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/search_modal_internal.tsx b/x-pack/platform/plugins/private/global_search_bar/public/components/search_modal_internal.tsx new file mode 100644 index 0000000000000..3afc6f9ab989f --- /dev/null +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/search_modal_internal.tsx @@ -0,0 +1,149 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiHorizontalRule, + EuiIcon, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiSelectable, + euiSelectableTemplateSitewideRenderOptions, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useEffect } from 'react'; +import { i18nStrings } from '../strings'; +import { SearchFooter } from './search_footer'; +import { SearchPlaceholder } from './search_placeholder'; +import { useSearchState } from '../hooks/use_search_state'; +import type { SearchModalProps } from './types'; +import { EmptyMessage } from './empty_message'; +import { + SEARCH_MODAL_KEYBOARD_SHORTCUT, + SEARCH_MODAL_ROW_HEIGHT, + SEARCH_MODAL_SELECTOR_PREFIX, +} from './types'; +import { CharLimitExceededMessage } from './char_limit_exceeded_message'; + +export const SearchModalInternal = ({ + globalSearch, + taggingApi, + navigateToUrl, + reportEvent, + basePathUrl, + onClose, +}: SearchModalProps) => { + const { euiTheme } = useEuiTheme(); + const { + searchValue, + setSearchValue, + options, + isLoading, + searchCharLimitExceeded, + onChange, + setSearchRef, + triggerInitialLoad, + } = useSearchState({ + globalSearch, + taggingApi, + navigateToUrl, + reportEvent, + onResultSelect: onClose, + }); + + useEffect(() => { + triggerInitialLoad(); + reportEvent.searchFocus(); + return () => { + reportEvent.searchBlur(); + }; + }, [triggerInitialLoad, reportEvent]); + + const formattedOptions = options.map((option) => ({ + ...option, + prepend: option.icon ? : option.prepend, + })); + + const selectableStyles = css` + display: flex; + flex-direction: column; + flex: 1 1 auto; + overflow: hidden; + `; + + const headerStyles = css` + padding-block: ${euiTheme.size.base}; + padding-inline: ${euiTheme.size.base}; + `; + + const bodyStyles = css` + .euiModalBody__overflow { + padding-inline: 0; + padding-block: 0; + display: flex; + flex-direction: column; + justify-content: ${isLoading || options.length === 0 ? 'center' : 'flex-start'}; + } + `; + + const footerStyles = css` + padding-block: ${euiTheme.size.s}; + padding-inline: ${euiTheme.size.base}; + `; + + return ( + euiSelectableTemplateSitewideRenderOptions(option, searchValue)} + listProps={{ + rowHeight: SEARCH_MODAL_ROW_HEIGHT, + showIcons: false, + }} + searchProps={{ + autoFocus: true, + value: searchValue, + onInput: (e: React.UIEvent) => setSearchValue(e.currentTarget.value), + 'data-test-subj': `${SEARCH_MODAL_SELECTOR_PREFIX}Input`, + inputRef: setSearchRef, + compressed: false, + 'aria-label': i18nStrings.placeholderText, + placeholder: i18nStrings.placeholderText, + fullWidth: true, + isClearable: true, + }} + noMatchesMessage={ + searchCharLimitExceeded ? undefined : + } + searchable + emptyMessage={} + > + {(list, search) => ( + <> + {search} + + + {searchCharLimitExceeded ? ( + + ) : ( + list + )} + + + + + + + )} + + ); +}; diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/popover_placeholder.tsx b/x-pack/platform/plugins/private/global_search_bar/public/components/search_placeholder.tsx similarity index 92% rename from x-pack/platform/plugins/private/global_search_bar/public/components/popover_placeholder.tsx rename to x-pack/platform/plugins/private/global_search_bar/public/components/search_placeholder.tsx index 2f0500f9c3993..4ea45851c2024 100644 --- a/x-pack/platform/plugins/private/global_search_bar/public/components/popover_placeholder.tsx +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/search_placeholder.tsx @@ -5,21 +5,20 @@ * 2.0. */ -import type { FC } from 'react'; import React from 'react'; import { EuiImage, EuiText, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -interface PopoverPlaceholderProps { +interface SearchPlaceholderProps { basePath: string; customPlaceholderMessage?: React.ReactNode; } -export const PopoverPlaceholder: FC = ({ +export const SearchPlaceholder = ({ basePath, customPlaceholderMessage, -}) => { +}: SearchPlaceholderProps) => { const { colorMode } = useEuiTheme(); return ( diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts b/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts index cacc278c91c75..86c000e364f5f 100644 --- a/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts @@ -5,19 +5,33 @@ * 2.0. */ -import type { ChromeStyle } from '@kbn/core-chrome-browser'; import type { ApplicationStart } from '@kbn/core/public'; import type { GlobalSearchPluginStart } from '@kbn/global-search-plugin/public'; import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; import type { Observable } from 'rxjs'; +import type { ChromeStyle } from '@kbn/core-chrome-browser'; import type { EventReporter } from '../telemetry'; +export const SEARCH_MODAL_SELECTOR_PREFIX = 'chromeProjectNextSearchModal'; +export const SEARCH_MODAL_HEIGHT = 50; +export const SEARCH_MODAL_WIDTH = 800; +export const SEARCH_MODAL_ROW_HEIGHT = 68; +export const SEARCH_MODAL_KEYBOARD_SHORTCUT = 'k'; + /* @internal */ -export interface SearchBarProps { +export interface SearchProps { globalSearch: GlobalSearchPluginStart & { searchCharLimit: number }; navigateToUrl: ApplicationStart['navigateToUrl']; reportEvent: EventReporter; taggingApi?: SavedObjectTaggingPluginStart; basePathUrl: string; +} + +/* @internal */ +export interface SearchBarProps extends SearchProps { chromeStyle$: Observable; } +/* @internal */ +export interface SearchModalProps extends SearchProps { + onClose: () => void; +} diff --git a/x-pack/platform/plugins/private/global_search_bar/public/hooks/use_search_state.ts b/x-pack/platform/plugins/private/global_search_bar/public/hooks/use_search_state.ts new file mode 100644 index 0000000000000..84190ba145158 --- /dev/null +++ b/x-pack/platform/plugins/private/global_search_bar/public/hooks/use_search_state.ts @@ -0,0 +1,288 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiSelectableTemplateSitewideOption } from '@elastic/eui'; +import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable'; +import type { RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Subscription } from 'rxjs'; +import type { GlobalSearchFindParams, GlobalSearchResult } from '@kbn/global-search-plugin/public'; +import useDebounce from 'react-use/lib/useDebounce'; +import { apm } from '@elastic/apm-rum'; +import useMountedState from 'react-use/lib/useMountedState'; +import type { SearchSuggestion } from '../suggestions'; +import { getSuggestions } from '../suggestions'; +import type { SearchProps } from '../components/types'; +import { resultToOption, suggestionToOption } from '../lib'; +import { parseSearchParams } from '../search_syntax'; +import { sort } from '../components'; + +const UNKNOWN_TAG_ID = '__unknown__'; + +interface UseSearchStateOptions extends Omit { + /** Called after a result is selected and navigation is triggered. */ + onResultSelect?: () => void; +} + +export interface SearchStateResult { + searchValue: string; + setSearchValue: (value: string) => void; + options: EuiSelectableTemplateSitewideOption[]; + isLoading: boolean; + searchCharLimitExceeded: boolean; + searchRef: RefObject; + setSearchRef: (ref: HTMLInputElement | null) => void; + triggerInitialLoad: () => void; + onChange: ( + selection: EuiSelectableTemplateSitewideOption[], + event: EuiSelectableOnChangeEvent + ) => void; +} + +export const useSearchState = ({ + globalSearch, + taggingApi, + navigateToUrl, + reportEvent, + onResultSelect, +}: UseSearchStateOptions): SearchStateResult => { + const isMounted = useMountedState(); + + const [initialLoad, setInitialLoad] = useState(false); + const [searchValue, setSearchValue] = useState(''); + const [options, setOptions] = useState([]); + const [searchableTypes, setSearchableTypes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [searchCharLimitExceeded, setSearchCharLimitExceeded] = useState(false); + + const searchSubscription = useRef(null); + const searchRef = useRef(null); + + const setSearchRef = useCallback((ref: HTMLInputElement | null) => { + searchRef.current = ref; + }, []); + + // Initialize searchableTypes data + useEffect(() => { + if (initialLoad) { + const fetch = async () => { + const types = await globalSearch.getSearchableTypes(); + setSearchableTypes(types); + }; + fetch(); + } + }, [globalSearch, initialLoad]); + + // Whenever searchValue changes, isLoading = true + useEffect(() => { + setIsLoading(true); + }, [searchValue]); + + // Cleanup subscription when component unmounts + useEffect(() => { + return () => { + searchSubscription.current?.unsubscribe(); + }; + }, []); + + const triggerInitialLoad = useCallback(() => { + setInitialLoad(true); + }, []); + + const loadSuggestions = useCallback( + (term: string) => { + return getSuggestions({ + searchTerm: term, + searchableTypes, + tagCache: taggingApi?.cache, + }); + }, + [taggingApi, searchableTypes] + ); + + const setDecoratedOptions = useCallback( + ( + _options: GlobalSearchResult[], + suggestions: SearchSuggestion[], + searchTagIds: string[] = [] + ) => { + setOptions([ + ...suggestions.map(suggestionToOption), + ..._options.map((option) => + resultToOption( + option, + searchTagIds?.filter((id) => id !== UNKNOWN_TAG_ID) ?? [], + taggingApi?.ui.getTagList + ) + ), + ]); + }, + [setOptions, taggingApi] + ); + + useDebounce( + () => { + if (initialLoad) { + // cancel pending search if not completed yet + if (searchSubscription.current) { + searchSubscription.current.unsubscribe(); + searchSubscription.current = null; + } + + if (searchValue.length > globalSearch.searchCharLimit) { + // setting this will display an error message to the user + setSearchCharLimitExceeded(true); + return; + } else { + setSearchCharLimitExceeded(false); + } + + const suggestions = loadSuggestions(searchValue.toLowerCase()); + + let aggregatedResults: GlobalSearchResult[] = []; + + if (searchValue.length !== 0) { + reportEvent.searchRequest(); + } + + const rawParams = parseSearchParams(searchValue.toLowerCase(), searchableTypes); + let tagIds: string[] | undefined; + if (taggingApi && rawParams.filters.tags) { + tagIds = rawParams.filters.tags.map( + (tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? UNKNOWN_TAG_ID + ); + } else { + tagIds = undefined; + } + const searchParams: GlobalSearchFindParams = { + term: rawParams.term, + types: rawParams.filters.types, + tags: tagIds, + }; + + searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({ + next: ({ results }) => { + if (!isMounted()) { + return; + } + + if (searchValue.length > 0) { + aggregatedResults = [...results, ...aggregatedResults].sort(sort.byScore); + setDecoratedOptions(aggregatedResults, suggestions, searchParams.tags); + return; + } + + // if searchbar is empty, filter to only applications and sort alphabetically + results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); + aggregatedResults = [...results, ...aggregatedResults].sort(sort.byTitle); + setDecoratedOptions(aggregatedResults, suggestions, searchParams.tags); + }, + error: (err) => { + setIsLoading(false); + + // Not doing anything on error right now because it'll either just show the previous + // results or empty results which is basically what we want anyways + apm.captureError(err, { + labels: { + SearchValue: searchValue, + }, + }); + }, + complete: () => { + setIsLoading(false); + }, + }); + } + }, + 350, + [searchValue, loadSuggestions, searchableTypes, initialLoad] + ); + + const onResultSelectRef = useRef(onResultSelect); + onResultSelectRef.current = onResultSelect; + + const onChange = useCallback( + (selection: EuiSelectableTemplateSitewideOption[], event: EuiSelectableOnChangeEvent) => { + let selectedRank: number | null = null; + const selected = selection.find(({ checked }, rank) => { + const isChecked = checked === 'on'; + if (isChecked) { + selectedRank = rank + 1; + } + return isChecked; + }); + + if (!selected) { + return; + } + + const selectedLabel = selected.label ?? null; + + // @ts-ignore - ts error is "union type is too complex to express" + const { url, type, suggestion } = selected; + + // if the type is a suggestion, we change the query on the input and trigger a new search + // by setting the searchValue (only setting the field value does not trigger a search) + if (type === '__suggestion__') { + setSearchValue(suggestion); + return; + } + + // errors in tracking should not prevent selection behavior + try { + if (type === 'application') { + const key = selected.key ?? 'unknown'; + const application = `${key.toLowerCase().replaceAll(' ', '_')}`; + reportEvent.navigateToApplication({ + application, + searchValue, + selectedLabel, + selectedRank, + }); + } else { + reportEvent.navigateToSavedObject({ + type, + searchValue, + selectedLabel, + selectedRank, + }); + } + } catch (err) { + apm.captureError(err, { + labels: { + SearchValue: searchValue, + }, + }); + // eslint-disable-next-line no-console + console.log('Error trying to track searchbar metrics', err); + } + + if (event.shiftKey) { + window.open(url); + } else if (event.ctrlKey || event.metaKey) { + window.open(url, '_blank'); + } else { + navigateToUrl(url); + } + + onResultSelectRef.current?.(); + }, + [reportEvent, navigateToUrl, searchValue] + ); + + return { + searchValue, + setSearchValue, + options, + isLoading, + searchCharLimitExceeded, + onChange, + setSearchRef, + searchRef, + triggerInitialLoad, + }; +}; diff --git a/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx b/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx index 80ec27848aacf..e3ebd35c0e7bb 100644 --- a/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx +++ b/x-pack/platform/plugins/private/global_search_bar/public/plugin.tsx @@ -5,14 +5,27 @@ * 2.0. */ -import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { + CoreSetup, + CoreStart, + OverlayRef, + Plugin, + PluginInitializerContext, +} from '@kbn/core/public'; import type { GlobalSearchPluginStart } from '@kbn/global-search-plugin/public'; import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import React from 'react'; +import { toMountPoint } from '@kbn/react-kibana-mount'; import { SearchBar } from './components/search_bar'; import type { GlobalSearchBarConfigType } from './types'; import { EventReporter, eventTypes } from './telemetry'; +import { + SEARCH_MODAL_KEYBOARD_SHORTCUT, + SEARCH_MODAL_SELECTOR_PREFIX, + type SearchProps, +} from './components/types'; +import { SearchModal } from './components/search_modal'; export interface GlobalSearchBarPluginStartDeps { globalSearch: GlobalSearchPluginStart; @@ -40,26 +53,52 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}, {}, GlobalSearchBar const { application, http } = core; const reportEvent = new EventReporter({ analytics: core.analytics, usageCollection }); + const searchProps: SearchProps = { + globalSearch: { ...globalSearch, searchCharLimit: this.config.input_max_limit }, + navigateToUrl: application.navigateToUrl, + taggingApi: savedObjectsTagging, + basePathUrl: http.basePath.prepend('/plugins/globalSearchBar/assets/'), + reportEvent, + }; + + let activeModalRef: OverlayRef | null = null; + + const toggleSearchModal = () => { + if (activeModalRef) { + activeModalRef.close(); + activeModalRef = null; + return; + } + activeModalRef = core.overlays.openModal( + toMountPoint( + { + activeModalRef?.close(); + activeModalRef = null; + }} + />, + core + ), + { + className: SEARCH_MODAL_SELECTOR_PREFIX, + 'data-test-subj': SEARCH_MODAL_SELECTOR_PREFIX, + outsideClickCloses: true, + } + ); + activeModalRef.onClose.then(() => { + activeModalRef = null; + }); + }; + core.chrome.next.globalSearch.set({ - onClick: () => { - // TODO: open search modal - // eslint-disable-next-line no-console - console.log('[GlobalSearch] search button clicked — modal not yet implemented'); - }, + onClick: toggleSearchModal, + shortcutKey: SEARCH_MODAL_KEYBOARD_SHORTCUT, }); core.chrome.navControls.registerCenter({ order: 1000, - content: ( - - ), + content: , }); return {}; diff --git a/x-pack/platform/plugins/private/global_search_bar/tsconfig.json b/x-pack/platform/plugins/private/global_search_bar/tsconfig.json index 19d91b17684ad..f86a30e38cccb 100644 --- a/x-pack/platform/plugins/private/global_search_bar/tsconfig.json +++ b/x-pack/platform/plugins/private/global_search_bar/tsconfig.json @@ -15,7 +15,8 @@ "@kbn/saved-objects-tagging-oss-plugin", "@kbn/core-chrome-browser", "@kbn/config-schema", - "@kbn/shared-ux-utility" + "@kbn/shared-ux-utility", + "@kbn/react-kibana-mount" ], "exclude": [ "target/**/*", From 8d4ccd1eeaf96340aba901c3f415b602ae1bd8cd Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Wed, 8 Apr 2026 22:23:41 +0200 Subject: [PATCH 21/77] Lint and quick check fixes --- packages/kbn-optimizer/limits.yml | 6 +++--- .../public/dashboard_top_nav/internal_dashboard_top_nav.tsx | 1 - .../plugins/private/translations/translations/de-DE.json | 2 -- .../plugins/private/translations/translations/fr-FR.json | 2 -- .../plugins/private/translations/translations/ja-JP.json | 2 -- .../plugins/private/translations/translations/zh-CN.json | 2 -- .../security/plugins/elastic_assistant/public/plugin.tsx | 2 +- 7 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 5e91a482b5ef8..abe9e9af58f95 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -26,7 +26,7 @@ pageLoadAssetSize: contentConnectors: 33014 contentManagement: 8350 controls: 10300 - core: 547151 + core: 604282 cps: 9209 crossClusterReplication: 12662 customIntegrations: 11715 @@ -79,7 +79,7 @@ pageLoadAssetSize: fleet: 209495 genAiSettings: 5663 globalSearch: 6890 - globalSearchBar: 26122 + globalSearchBar: 31178 globalSearchProviders: 4646 graph: 9924 grokdebugger: 5469 @@ -173,7 +173,7 @@ pageLoadAssetSize: share: 58677 slo: 40437 snapshotRestore: 22068 - spaces: 28871 + spaces: 36639 stackAlerts: 31499 stackConnectors: 85421 streams: 12500 diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index 16c9fa3d1400b..deaca8cdaaacf 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -355,7 +355,6 @@ export function InternalDashboardTopNav({ isOpen={isPopoverOpen} closePopover={() => setIsPopoverOpen(false)} panelStyle={{ maxWidth: 250 }} - aria-label={dashboardManagedBadge.getBadgeAriaLabel()} > { + content: (target: HTMLElement) => { const startService = startServices(); return this.mountAIAssistantButton(target, coreStart, startService); }, From 6e1980a66a5913e3a01e855497667af1e81aaf50 Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Thu, 9 Apr 2026 10:22:51 +0200 Subject: [PATCH 22/77] [FeatureBranch/DoNotReview][Chrome Next] Make discover use ChromeNextHeader (#262106) ## Summary This PR makes discover use the new app menu API when feature flag is on. It also makes `title` optional, otherwise the setter would show TS errors: ``` Argument of type '{ appMenu: AppMenuConfig; }' is not assignable to parameter of type 'ChromeNextHeaderConfig'. Property 'title' is missing in type '{ appMenu: AppMenuConfig; }' but required in type 'ChromeNextHeaderConfig'. ``` Closes: https://github.com/elastic/kibana/issues/261913 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../chrome/browser/src/chrome_next/header.ts | 2 +- src/core/packages/chrome/browser/tsconfig.json | 2 +- .../discover/public/__mocks__/services.ts | 1 + .../single_tab_view_with_app_menu.tsx | 18 +++++++++++++++--- .../components/tabs_view/hide_tabs_bar.tsx | 14 ++++++++++++-- .../main/components/tabs_view/tabs_view.tsx | 16 ++++++++++++++-- .../shared/discover/public/build_services.ts | 3 +++ .../utils/serialization_utils.test.ts | 1 + .../plugins/shared/discover/tsconfig.json | 1 + 9 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/core/packages/chrome/browser/src/chrome_next/header.ts b/src/core/packages/chrome/browser/src/chrome_next/header.ts index e25127cf5bffd..795a68924bb88 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/header.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/header.ts @@ -20,7 +20,7 @@ export interface ChromeNextHeaderConfig { * Can be the app name (e.g. "Discover") or a viewed object * (e.g. a saved dashboard name). Single-line, truncated by Chrome if too long. */ - title: string; + title?: string; /** * Badges inline next to the title. Chrome shows 1–2 as-is; for 3+, first badge plus "+N" popover diff --git a/src/core/packages/chrome/browser/tsconfig.json b/src/core/packages/chrome/browser/tsconfig.json index 425262ddf2a1c..66dc2cfd32820 100644 --- a/src/core/packages/chrome/browser/tsconfig.json +++ b/src/core/packages/chrome/browser/tsconfig.json @@ -30,7 +30,7 @@ "@kbn/deeplinks-workflows", "@kbn/deeplinks-agent-builder", "@kbn/core-chrome-navigation", - "@kbn/core-chrome-sidebar" + "@kbn/core-chrome-sidebar", ], "exclude": [ "target/**/*", diff --git a/src/platform/plugins/shared/discover/public/__mocks__/services.ts b/src/platform/plugins/shared/discover/public/__mocks__/services.ts index 4ce5714b191b1..985491f0733f0 100644 --- a/src/platform/plugins/shared/discover/public/__mocks__/services.ts +++ b/src/platform/plugins/shared/discover/public/__mocks__/services.ts @@ -331,6 +331,7 @@ export function createDiscoverServicesMock(): DiscoverServices { getCascadeLayoutEnabled: jest.fn(() => false), getIsEsqlDefault: jest.fn(() => false), getEmbeddableTransformsEnabled: jest.fn(() => true), + getIsNextChrome: jest.fn(() => false), }, embeddableEditor: { isByValueEditor: jest.fn(() => false), diff --git a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx index 300d38a72f34b..8846dd758ce05 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx @@ -7,19 +7,31 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React from 'react'; +import React, { useEffect } from 'react'; import { AppMenu } from '@kbn/core-chrome-app-menu'; import { SingleTabView, type SingleTabViewProps } from '.'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { useTopNavMenuItems } from '../top_nav/use_top_nav_menu_items'; export const SingleTabViewWithAppMenu = (props: SingleTabViewProps) => { - const { chrome } = useDiscoverServices(); + const { chrome, discoverFeatureFlags } = useDiscoverServices(); + const isNextChrome = discoverFeatureFlags.getIsNextChrome(); const topNavMenuItems = useTopNavMenuItems(); + useEffect(() => { + if (isNextChrome && topNavMenuItems) { + chrome.next.header.set({ appMenu: topNavMenuItems }); + return () => { + chrome.next.header.set(undefined); + }; + } + }, [isNextChrome, topNavMenuItems, chrome.next.header]); + return ( <> - {topNavMenuItems && } + {!isNextChrome && topNavMenuItems && ( + + )} ); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx index f3f00e8c65cf4..0bae1e40c41a0 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx @@ -24,7 +24,8 @@ export const HideTabsBar: FC<{ children: ReactNode; }> = ({ customizationContext, children }) => { const dispatch = useInternalStateDispatch(); - const { chrome } = useDiscoverServices(); + const { chrome, discoverFeatureFlags } = useDiscoverServices(); + const isNextChrome = discoverFeatureFlags.getIsNextChrome(); const topNavMenuItems = useTopNavMenuItems(); useEffect(() => { @@ -34,13 +35,22 @@ export const HideTabsBar: FC<{ }; }, [dispatch]); + useEffect(() => { + if (isNextChrome && customizationContext.displayMode === 'standalone' && topNavMenuItems) { + chrome.next.header.set({ appMenu: topNavMenuItems }); + return () => { + chrome.next.header.set(undefined); + }; + } + }, [isNextChrome, customizationContext.displayMode, topNavMenuItems, chrome.next.header]); + return ( <> { /** * The tabs bar renders the app menu, but it still needs to be shown when tabs are hidden */ - customizationContext.displayMode === 'standalone' && topNavMenuItems && ( + !isNextChrome && customizationContext.displayMode === 'standalone' && topNavMenuItems && ( ) } diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index 5c0019e70e2fc..b944bf68e72f1 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { EuiResizeObserver } from '@elastic/eui'; import { UnifiedTabs, type UnifiedTabsProps } from '@kbn/unified-tabs'; import { AppMenuComponent } from '@kbn/core-chrome-app-menu-components'; @@ -39,6 +39,7 @@ export const TabsView = (props: SingleTabViewProps) => { const unsavedTabIds = useInternalStateSelector((state) => state.tabs.unsavedIds); const currentDataView = useCurrentTabRuntimeState((tab) => tab.currentDataView$); const scopedEbtManager = useCurrentTabRuntimeState((tab) => tab.scopedEbtManager$); + const isNextChrome = services.discoverFeatureFlags.getIsNextChrome(); const { shouldCollapseAppMenu, @@ -48,6 +49,15 @@ export const TabsView = (props: SingleTabViewProps) => { topNavMenuItems, } = useAppMenuData({ currentDataView }); + useEffect(() => { + if (isNextChrome && topNavMenuItems) { + services.chrome.next.header.set({ appMenu: topNavMenuItems }); + return () => { + services.chrome.next.header.set(undefined); + }; + } + }, [isNextChrome, topNavMenuItems, services.chrome.next.header]); + const onEvent: UnifiedTabsProps['onEBTEvent'] = useCallback( (event) => { void scopedEbtManager.trackTabsEvent(event); @@ -100,7 +110,9 @@ export const TabsView = (props: SingleTabViewProps) => { getTopTabMenuItems={getTopTabMenuItems} getAdditionalTabMenuItems={getAdditionalTabMenuItems} appendRight={ - + !isNextChrome ? ( + + ) : undefined } /> diff --git a/src/platform/plugins/shared/discover/public/build_services.ts b/src/platform/plugins/shared/discover/public/build_services.ts index d5b2195c4d00d..303ef057a8c65 100644 --- a/src/platform/plugins/shared/discover/public/build_services.ts +++ b/src/platform/plugins/shared/discover/public/build_services.ts @@ -65,6 +65,7 @@ import type { LogsDataAccessPluginStart } from '@kbn/logs-data-access-plugin/pub import type { DiscoverSharedPublicStart } from '@kbn/discover-shared-plugin/public'; import type { CPSPluginStart } from '@kbn/cps/public'; import type { AlertingV2PublicStart } from '@kbn/alerting-v2-plugin/public'; +import { isNextChrome } from '@kbn/core-chrome-feature-flags'; import type { DiscoverStartPlugins } from './types'; import type { DiscoverContextAppLocator } from './application/context/services/locator'; import type { DiscoverSingleDocLocator } from './application/doc/locator'; @@ -95,6 +96,7 @@ export interface DiscoverFeatureFlags { getCascadeLayoutEnabled: () => boolean; getIsEsqlDefault: () => boolean; getEmbeddableTransformsEnabled: () => boolean; + getIsNextChrome: () => boolean; } export interface DiscoverServices { @@ -212,6 +214,7 @@ export const buildServices = ({ core.featureFlags.getBooleanValue(IS_ESQL_DEFAULT_FEATURE_FLAG_KEY, false), getEmbeddableTransformsEnabled: () => core.featureFlags.getBooleanValue(EMBEDDABLE_TRANSFORMS_FEATURE_FLAG_KEY, true), + getIsNextChrome: () => isNextChrome(core.featureFlags), }, docLinks: core.docLinks, embeddable: plugins.embeddable, diff --git a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts index 1503a7873b5db..cc2ddb153a8e0 100644 --- a/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts +++ b/src/platform/plugins/shared/discover/public/embeddable/utils/serialization_utils.test.ts @@ -39,6 +39,7 @@ describe('Serialization utils', () => { getCascadeLayoutEnabled: jest.fn(() => false), getIsEsqlDefault: jest.fn(() => false), getEmbeddableTransformsEnabled: jest.fn(() => false), + getIsNextChrome: jest.fn(() => false), }, } satisfies DiscoverServices; diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index 3e218435e8a2b..b2257c9f270d6 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -138,6 +138,7 @@ "@kbn/presentation-util-plugin", "@kbn/core-saved-objects-common", "@kbn/as-code-filters-constants", + "@kbn/core-chrome-feature-flags", ], "exclude": ["target/**/*"] } From fc3ab3ec9e0cb5b980a4471298a46e36cd196018 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 9 Apr 2026 16:09:25 +0200 Subject: [PATCH 23/77] [FeatureBranch/DoNotReview][Chrome Next] Make User and Help menus support onClick items (#262206) ## Summary Follows up to https://github.com/elastic/kibana/pull/261572, https://github.com/elastic/kibana/pull/261525 Make side nav menus support onClick tool items: -Appearance -Connection details Screenshot 2026-04-09 at 10 45 10 Screenshot 2026-04-09 at 10 45 20 --- .../navigation/to_chrome_tool_slots.ts | 30 +++--- .../browser/src/chrome_next/user_menu.ts | 3 +- .../navigation/packaging/react/types.ts | 5 +- .../navigation/src/components/navigation.tsx | 56 ++++++---- src/core/packages/chrome/navigation/types.ts | 10 +- .../plugins/shared/dashboard/moon.yml | 2 +- .../src/nav_control/nav_control_service.ts | 7 +- .../appearance_selector.test.tsx | 102 +----------------- .../appearance_selector.tsx | 91 +++++----------- .../appearance_selector/index.ts | 2 +- .../maybe_add_cloud_links/user_menu_links.tsx | 29 +++-- .../private/global_search_bar/moon.yml | 1 + .../nav_control/nav_control_component.tsx | 56 ++++------ .../nav_control/nav_control_service.test.ts | 11 +- .../nav_control/nav_control_service.tsx | 9 +- 15 files changed, 151 insertions(+), 263 deletions(-) diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts index 42b702299c120..71122c4ad93c7 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts @@ -99,13 +99,16 @@ const getUserMenuToolItem = ( return undefined; } - const items: SecondaryMenuItem[] = config.items.map((item) => ({ - id: item.id, - label: item.label, - href: item.href, - isExternal: item.isExternal, - 'data-test-subj': item['data-test-subj'], - })); + const items: SecondaryMenuItem[] = config.items + .filter((item) => Boolean(item.href) || Boolean(item.onClick)) + .map((item) => ({ + id: item.id, + label: item.label, + ...(item.href && { href: item.href }), + ...(item.onClick && { onClick: item.onClick }), + isExternal: item.isExternal, + 'data-test-subj': item['data-test-subj'], + })); if (items.length === 0) { return undefined; @@ -158,17 +161,14 @@ const buildHelpSections = (helpLinks: HelpLinks): SecondaryMenuSection[] => { const toSecondaryItems = (items: HelpMenuLinkItem[]): SecondaryMenuItem[] => items .filter( - ( - item - ): item is typeof item & { - href: string; - label: string; - } => Boolean(item.href) && typeof item.label === 'string' + (item): item is typeof item & { label: string } => + (Boolean(item.href) || Boolean(item.onClick)) && typeof item.label === 'string' ) - .map(({ id, label, href, 'data-test-subj': dataTestSubj, isExternal }) => ({ + .map(({ id, label, href, onClick, 'data-test-subj': dataTestSubj, isExternal }) => ({ id, label, - href, + ...(href && { href }), + ...(onClick && { onClick }), 'data-test-subj': dataTestSubj, isExternal, })); diff --git a/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts b/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts index 56e163cfca722..4869c7e0098ec 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts @@ -18,7 +18,8 @@ export interface ChromeNextUserMenuConfig { export interface ChromeNextUserMenuItem { id: string; label: string; - href: string; + href?: string; isExternal?: boolean; 'data-test-subj'?: string; + onClick?: () => void; } diff --git a/src/core/packages/chrome/navigation/packaging/react/types.ts b/src/core/packages/chrome/navigation/packaging/react/types.ts index 7daadb2afa9ca..312cd6b1f8561 100644 --- a/src/core/packages/chrome/navigation/packaging/react/types.ts +++ b/src/core/packages/chrome/navigation/packaging/react/types.ts @@ -26,10 +26,11 @@ export type BadgeType = 'beta' | 'techPreview' | 'new'; /** * A navigation item within a secondary/nested menu. + * At least one of `href` or `onClick` must be provided. */ export interface SecondaryMenuItem { /** URL or hash for navigation. */ - href: string; + href?: string; /** Unique identifier for this item. */ id: string; /** Display text for the menu item. */ @@ -40,6 +41,8 @@ export interface SecondaryMenuItem { badgeType?: BadgeType; /** If true, opens link in a new tab with an external icon. */ isExternal?: boolean; + /** Click handler for action items (e.g. opening a flyout or modal). */ + onClick?: () => void; } /** diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 4b9a1597de1b6..9cfe7763d287a 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -257,6 +257,7 @@ export const Navigation = ({ const subItemAriaDescribedBy = isFirstSubItem ? ids.popoverNavigationInstructionsId : undefined; + const { onClick: subItemOnClick, ...subItemRest } = subItem; return ( { + subItemOnClick?.(); onItemClick?.(subItem); if (subItem.href) { closePopover(); } }} testSubjPrefix={popoverItemPrefix} - {...subItem} + {...subItemRest} > {subItem.label} @@ -378,22 +380,26 @@ export const Navigation = ({ key={section.id} label={section.label} > - {section.items.map((subItem) => ( - { - onItemClick?.(subItem); - closePopover(); - focusMainContent(); - }} - {...subItem} - > - {subItem.label} - - ))} + {section.items.map((subItem) => { + const { onClick: subItemOnClick, ...subItemRest } = subItem; + return ( + { + subItemOnClick?.(); + onItemClick?.(subItem); + closePopover(); + focusMainContent(); + }} + {...subItemRest} + > + {subItem.label} + + ); + })} ))} @@ -470,6 +476,7 @@ export const Navigation = ({ const ariaDescribedBy = isFirstItem ? secondaryNavigationInstructionsId : undefined; + const { onClick: subItemOnClick, ...subItemRest } = subItem; return ( onItemClick?.(subItem)} + onClick={() => { + subItemOnClick?.(); + onItemClick?.(subItem); + }} testSubjPrefix={sidePanelItemPrefix} - {...subItem} + {...subItemRest} > {subItem.label} @@ -570,6 +580,7 @@ const renderFooterNavItems = ({ const subItemAriaDescribedBy = isFirstSubItem ? ids.popoverNavigationInstructionsId : undefined; + const { onClick: subItemOnClick, ...subItemRest } = subItem; return ( { + subItemOnClick?.(); onItemClick?.(subItem); if (subItem.href) { closePopover(); } }} - {...subItem} + {...subItemRest} testSubjPrefix={popoverItemPrefix} > {subItem.label} @@ -671,6 +683,7 @@ const renderToolItems = ({ const subItemAriaDescribedBy = isFirstSubItem ? ids.popoverNavigationInstructionsId : undefined; + const { onClick: subItemOnClick, ...subItemRest } = subItem; return ( { + subItemOnClick?.(); closePopover(); }} - {...subItem} + {...subItemRest} testSubjPrefix={popoverItemPrefix} > {subItem.label} diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index 10c9f9e1bad5a..4b0fc7c1faa02 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -15,12 +15,14 @@ export type BadgeType = 'beta' | 'techPreview' | 'new'; /** * A navigation item within a secondary/nested menu. * Secondary items appear when a top-level navigation item with sections is clicked or hovered. + * + * At least one of `href` or `onClick` must be provided. */ export interface SecondaryMenuItem { /** - * The URL for the menu item link. + * (optional) The URL for the menu item link. */ - href: string; + href?: string; /** * The unique identifier of the menu item. */ @@ -41,6 +43,10 @@ export interface SecondaryMenuItem { * (optional) Whether the link opens in a new tab. */ isExternal?: boolean; + /** + * (optional) Click handler for action items (e.g. opening a flyout or modal). + */ + onClick?: () => void; } /** diff --git a/src/platform/plugins/shared/dashboard/moon.yml b/src/platform/plugins/shared/dashboard/moon.yml index 667d1e67a75db..32346e1d65f4c 100644 --- a/src/platform/plugins/shared/dashboard/moon.yml +++ b/src/platform/plugins/shared/dashboard/moon.yml @@ -126,8 +126,8 @@ dependsOn: - '@kbn/core-http-common' - '@kbn/core-http-server-mocks' - '@kbn/core-http-browser' - - '@kbn/core-chrome-browser' - '@kbn/lens-common' + - '@kbn/core-chrome-browser' tags: - plugin - prod diff --git a/x-pack/platform/packages/shared/security/plugin_types_public/src/nav_control/nav_control_service.ts b/x-pack/platform/packages/shared/security/plugin_types_public/src/nav_control/nav_control_service.ts index 0163725f03126..c13653bd60fc5 100644 --- a/x-pack/platform/packages/shared/security/plugin_types_public/src/nav_control/nav_control_service.ts +++ b/x-pack/platform/packages/shared/security/plugin_types_public/src/nav_control/nav_control_service.ts @@ -6,18 +6,15 @@ */ import type { IconType } from '@elastic/eui'; -import type { MouseEvent, ReactNode } from 'react'; import type { Observable } from 'rxjs'; export interface UserMenuLink { label: string; iconType: IconType; - href: string; + href?: string; order?: number; setAsProfile?: boolean; - onClick?: (event: MouseEvent) => void; - /** Render a custom ReactNode instead of the default */ - content?: ReactNode | ((args: { closePopover: () => void }) => ReactNode); + onClick?: () => void; } export interface SecurityNavControlServiceStart { diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx index 9765c930b2fe3..b230bc02f306c 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.test.tsx @@ -5,58 +5,11 @@ * 2.0. */ -import { fireEvent, render } from '@testing-library/react'; -import React from 'react'; - import { coreMock } from '@kbn/core/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; -import { useUpdateUserProfile } from '@kbn/user-profile-components'; -import { AppearanceSelector } from './appearance_selector'; - -jest.mock('./appearance_modal', () => ({ - AppearanceModal: jest.fn().mockImplementation(({ closeModal, uiSettingsClient }) => { - return ( -
-
-
- - -
- ); - }), -})); - -jest.mock('@kbn/user-profile-components', () => { - const original = jest.requireActual('@kbn/user-profile-components'); - return { - ...original, - useUpdateUserProfile: jest.fn().mockImplementation(() => ({ - userProfileData: { - userSettings: { - darkMode: 'light', - contrastMode: 'standard', - }, - }, - isLoading: false, - update: jest.fn(), - userProfileLoaded: true, - })), - }; -}); +import { openAppearanceModal } from './appearance_selector'; -describe('AppearanceSelector', () => { - const closePopover = jest.fn(); +describe('openAppearanceModal', () => { let core: ReturnType; let security: ReturnType; @@ -64,59 +17,14 @@ describe('AppearanceSelector', () => { core = coreMock.createStart(); security = securityMock.createStart(); - (useUpdateUserProfile as jest.Mock).mockImplementation(() => ({ - userProfileData: { - userSettings: { - darkMode: 'light', - contrastMode: 'standard', - }, - }, - isLoading: false, - update: jest.fn(), - userProfileLoaded: true, - })); - - // Mock the openModal to return a ref with proper close method core.overlays.openModal.mockImplementation(() => ({ close: jest.fn(), onClose: Promise.resolve(), })); }); - it('renders correctly and opens the appearance modal', () => { - const { getByTestId } = render( - - ); - - const appearanceSelector = getByTestId('appearanceSelector'); - fireEvent.click(appearanceSelector); - - expect(core.overlays.openModal).toHaveBeenCalled(); - expect(closePopover).toHaveBeenCalled(); - }); - - it('does not render when appearance is not visible', () => { - (useUpdateUserProfile as jest.Mock).mockImplementation(() => ({ - userProfileData: null, - isLoading: false, - update: jest.fn(), - userProfileLoaded: true, - })); - - const { queryByTestId } = render( - - ); - - expect(queryByTestId('appearanceSelector')).toBeNull(); + it('opens the appearance modal', () => { + openAppearanceModal({ core, security, isServerless: false }); + expect(core.overlays.openModal).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx index 2cce0ef2233ae..eed4c93f88982 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/appearance_selector.tsx @@ -5,9 +5,7 @@ * 2.0. */ -import React, { useRef } from 'react'; -import { EuiContextMenuItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import React from 'react'; import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import { UserProfilesKibanaProvider } from '@kbn/user-profile-components'; import type { CoreStart } from '@kbn/core-lifecycle-browser'; @@ -15,75 +13,40 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import type { OverlayRef } from '@kbn/core-mount-utils-browser'; import { AppearanceModal } from './appearance_modal'; -import { useAppearance } from './use_appearance_hook'; -interface Props { - security: SecurityPluginStart; +interface OpenAppearanceModalParams { core: CoreStart; - closePopover: () => void; + security: SecurityPluginStart; isServerless: boolean; } -export const AppearanceSelector = ({ security, core, closePopover, isServerless }: Props) => { - return ( - - - - ); -}; - -function AppearanceSelectorUI({ security, core, closePopover, isServerless }: Props) { - const { isVisible } = useAppearance({ - uiSettingsClient: core.uiSettings, - defaultColorMode: 'space_default', - defaultContrastMode: 'standard', - }); +let appearanceModalRef: OverlayRef | null = null; - const modalRef = useRef(null); +export const openAppearanceModal = ({ + core, + security, + isServerless, +}: OpenAppearanceModalParams) => { + if (appearanceModalRef) { + return; + } const closeModal = () => { - modalRef.current?.close(); - modalRef.current = null; + appearanceModalRef?.close(); + appearanceModalRef = null; }; - const openModal = () => { - modalRef.current = core.overlays.openModal( - toMountPoint( - - - , - core - ), - { 'data-test-subj': 'appearanceModal', maxWidth: 600 } - ); - }; - - if (!isVisible) { - return null; - } - - return ( - { - openModal(); - closePopover(); - }} - data-test-subj="appearanceSelector" - > - {i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceLinkText', { - defaultMessage: 'Appearance', - })} - + appearanceModalRef = core.overlays.openModal( + toMountPoint( + + + , + core + ), + { 'data-test-subj': 'appearanceModal', maxWidth: 600 } ); -} +}; diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/index.ts b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/index.ts index cad2bbd3d6ae4..e420eaef2d49c 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/index.ts +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/appearance_selector/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { AppearanceSelector } from './appearance_selector'; +export { openAppearanceModal } from './appearance_selector'; diff --git a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx index f72dca1293fd9..33132ed1a11df 100644 --- a/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx +++ b/x-pack/platform/plugins/private/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx @@ -5,12 +5,11 @@ * 2.0. */ -import React from 'react'; import { i18n } from '@kbn/i18n'; import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { SecurityPluginStart, UserMenuLink } from '@kbn/security-plugin/public'; import type { CoreStart } from '@kbn/core/public'; -import { AppearanceSelector } from './appearance_selector'; +import { openAppearanceModal } from './appearance_selector'; export const createUserMenuLinks = async ({ core, @@ -62,20 +61,18 @@ export const createUserMenuLinks = async ({ }); } - userMenuLinks.push({ - content: ({ closePopover }) => ( - - ), - order: 400, - label: '', - iconType: '', - href: '', - }); + if (!core.uiSettings.isOverridden('theme:darkMode')) { + userMenuLinks.push({ + label: i18n.translate('xpack.cloudLinks.userMenuLinks.appearanceLinkText', { + defaultMessage: 'Appearance', + }), + iconType: 'brush', + onClick: () => { + openAppearanceModal({ core, security, isServerless }); + }, + order: 400, + }); + } return userMenuLinks; }; diff --git a/x-pack/platform/plugins/private/global_search_bar/moon.yml b/x-pack/platform/plugins/private/global_search_bar/moon.yml index f1dcc5d07b689..3bc7015ef0fd7 100644 --- a/x-pack/platform/plugins/private/global_search_bar/moon.yml +++ b/x-pack/platform/plugins/private/global_search_bar/moon.yml @@ -28,6 +28,7 @@ dependsOn: - '@kbn/core-chrome-browser' - '@kbn/config-schema' - '@kbn/shared-ux-utility' + - '@kbn/react-kibana-mount' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx index fa024c62bf9b0..e9a7c4e2aaf66 100644 --- a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx @@ -15,8 +15,8 @@ import { EuiLoadingSpinner, EuiPopover, } from '@elastic/eui'; -import type { FunctionComponent, MouseEvent, ReactNode } from 'react'; -import React, { Fragment, useState } from 'react'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; @@ -28,9 +28,8 @@ import { UserAvatar, type UserProfileAvatarData } from '@kbn/user-profile-compon import { getUserDisplayName, isUserAnonymous } from '../../common/model'; import { useCurrentUser, useUserProfile } from '../components'; -type ContextMenuItem = Omit & { - content?: ReactNode | ((args: { closePopover: () => void }) => ReactNode); - onClick?: (event: MouseEvent) => void; +type ContextMenuItem = Omit & { + onClick?: () => void; }; interface ContextMenuProps { @@ -40,31 +39,23 @@ interface ContextMenuProps { const ContextMenuContent = ({ items, closePopover }: ContextMenuProps) => { return ( - <> - - {items.map((item, i) => { - if (item.content) { - return ( - - {typeof item.content === 'function' ? item.content({ closePopover }) : item.content} - - ); - } - return ( - - {item.name} - - ); - })} - - + + {items.map((item, i) => ( + { + item.onClick?.(); + if (!item.href) closePopover(); + }} + data-test-subj={item['data-test-subj']} + > + {item.name} + + ))} + ); }; @@ -117,13 +108,12 @@ export const SecurityNavControl: FunctionComponent = ({ if (userMenuLinks.length) { const userMenuLinkMenuItems = userMenuLinks .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) - .map(({ label, iconType, href, onClick, content }: UserMenuLink) => ({ + .map(({ label, iconType, href, onClick }: UserMenuLink) => ({ name: label, icon: , href, onClick, 'data-test-subj': `userMenuLink__${label}`, - content, })); items.push(...userMenuLinkMenuItems); } @@ -132,7 +122,7 @@ export const SecurityNavControl: FunctionComponent = ({ const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); if (!isAnonymous && !hasCustomProfileLinks) { - const profileMenuItem: EuiContextMenuPanelItemDescriptor = { + const profileMenuItem: ContextMenuItem = { name: ( ) => ({ - userProfiles: new UserProfileAPIClient(http), - users: new UserAPIClient(http), -}); +const mockApiClients = (http: ReturnType) => { + http.get.mockResolvedValue({}); + return { + userProfiles: new UserProfileAPIClient(http), + users: new UserAPIClient(http), + }; +}; describe('SecurityNavControlService', () => { it('registers a ReactNode content for the nav control', () => { diff --git a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx index e4d84f8f7b42e..af24eb926241d 100644 --- a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx @@ -171,13 +171,18 @@ export class SecurityNavControlService { } for (const link of sorted) { - if (link.content || !link.label || !link.href) { + if (!link.label || (!link.href && !link.onClick)) { continue; } items.push({ id: `userMenuLink__${link.label}`, label: link.label, - href: link.href, + ...(link.href && { href: link.href }), + ...(link.onClick && { + onClick: () => { + link.onClick?.(); + }, + }), 'data-test-subj': `userMenuLink__${link.label}`, }); } From b5a7adb697d0fa494675e238942030fa797754f3 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 10 Apr 2026 14:50:06 +0200 Subject: [PATCH 24/77] [Chrome Next] Pivot: revert sidenav rendering glue and navigation package (#262524) Part of elastic/kibana-team#3115 ## Summary After the Chrome-Next pivot (keeping the global header instead of eliminating it), the sidenav no longer needs to render globalSearch/userMenu/spaceSelector tool items. This PR removes the sidenav rendering glue and resets the navigation package to main: - Remove `to_chrome_tool_slots.ts` and the `combineLatest` subscription in `navigation.tsx` that wired `chrome.next` APIs to sidenav tool slots - Reset `@kbn/core-chrome-navigation` package to upstream main, reverting ToolItem renderContent/renderPopover, IconButton, HeaderToolbar/FooterToolbar, and related types/stories/tests - Add missing `@kbn/core-chrome-feature-flags` dependency to Discover's `moon.yml` The `chrome.next.globalSearch`, `chrome.next.userMenu`, and `chrome.next.spaceSelector` APIs are intentionally kept. They will be connected to the global header in a follow-up. Even though they will most likely need significant changes. --- .../project/sidenav/navigation/navigation.tsx | 51 +- .../navigation/to_chrome_tool_slots.ts | 174 ------- .../packages/chrome/navigation/README.mdx | 31 -- src/core/packages/chrome/navigation/index.ts | 2 - .../chrome/navigation/packaging/README.md | 3 - .../navigation/packaging/example/src/app.tsx | 36 +- .../packaging/react/type_validation.ts | 63 --- .../navigation/packaging/react/types.ts | 47 +- .../src/__stories__/navigation.stories.tsx | 243 +-------- .../__snapshots__/both_modes.test.tsx.snap | 71 ++- .../collapsed_mode.test.tsx.snap | 83 ++- .../__snapshots__/expanded_mode.test.tsx.snap | 71 ++- .../src/__tests__/both_modes.test.tsx | 6 +- .../src/components/footer/index.tsx | 133 ++++- .../navigation/src/components/footer/item.tsx | 134 ++++- .../navigation/src/components/footer/nav.tsx | 118 ----- .../src/components/footer/toolbar.tsx | 73 --- .../src/components/header/index.tsx | 52 -- .../src/components/icon_button/index.tsx | 194 ------- .../src/components/menu_item/index.tsx | 8 +- .../navigation/src/components/navigation.tsx | 472 +++++------------- .../src/components/side_nav/index.tsx | 14 +- .../src/components/side_nav/logo.tsx | 21 +- .../src/components/side_nav/popover.tsx | 67 +-- .../src/components/side_nav/side_panel.tsx | 2 +- .../src/components/tool_item/index.tsx | 35 -- .../navigation/src/hooks/use_navigation.ts | 2 +- .../src/hooks/use_tool_shortcuts.ts | 50 -- .../navigation/src/utils/get_has_submenu.ts | 9 +- src/core/packages/chrome/navigation/types.ts | 62 +-- src/platform/plugins/shared/discover/moon.yml | 1 + 31 files changed, 533 insertions(+), 1795 deletions(-) delete mode 100644 src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts delete mode 100644 src/core/packages/chrome/navigation/src/components/footer/nav.tsx delete mode 100644 src/core/packages/chrome/navigation/src/components/footer/toolbar.tsx delete mode 100644 src/core/packages/chrome/navigation/src/components/header/index.tsx delete mode 100644 src/core/packages/chrome/navigation/src/components/icon_button/index.tsx delete mode 100644 src/core/packages/chrome/navigation/src/components/tool_item/index.tsx delete mode 100644 src/core/packages/chrome/navigation/src/hooks/use_tool_shortcuts.ts diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx index 3cb66391d61b2..de3343b6ee4ca 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx @@ -8,19 +8,16 @@ */ import React, { useMemo } from 'react'; -import { combineLatest, map } from 'rxjs'; +import { map } from 'rxjs'; import { Navigation as NavigationComponent } from '@kbn/core-chrome-navigation'; import classnames from 'classnames'; import type { SolutionId } from '@kbn/core-chrome-browser'; import { useObservable } from '@kbn/use-observable'; import { useChromeService } from '@kbn/core-chrome-browser-context'; import { KibanaSectionErrorBoundary } from '@kbn/shared-ux-error-boundary'; -import type { ToolSlots } from '@kbn/core-chrome-navigation/types'; import { useBasePath, useIsNextChrome } from '../../../shared/chrome_hooks'; -import { useHelpLinks$ } from '../../../shared/help_links_hooks'; import type { NavigationItems } from './to_navigation_items'; import { toNavigationItems } from './to_navigation_items'; -import { buildToolSlots } from './to_chrome_tool_slots'; import { PanelStateManager } from './panel_state_manager'; export interface ChromeNavigationProps { @@ -36,13 +33,12 @@ export const Navigation = (props: ChromeNavigationProps) => { return null; } - const { navItems, logoItem, activeItemId, solutionId, toolSlots } = state; + const { navItems, logoItem, activeItemId, solutionId } = state; return ( { const chrome = useChromeService(); const basePath = useBasePath(); - const helpLinks$ = useHelpLinks$(); const isNextChrome = useIsNextChrome(); const items$ = useMemo(() => { const panelStateManager = new PanelStateManager(basePath.get()); - const navState$ = chrome.project.getNavigation$().pipe( + return chrome.project.getNavigation$().pipe( map((nav) => { const { navItems, logoItem, activeItemId } = toNavigationItems( nav.navigationTree, @@ -81,48 +75,13 @@ const useNavigationItems = (): NavigationState | null => { ); return { solutionId: nav.solutionId, - // In the `next-chrome` we want to show the elastic logo instead of the default product logo - logoItem: isNextChrome - ? { - ...logoItem, - iconType: 'logoElastic' as const, - iconColor: 'text' as const, - hideLabel: true, - } - : logoItem, + logoItem: isNextChrome ? logoItem : logoItem, activeItemId, navItems, }; }) ); - - const emptyToolSlots: ToolSlots = { headerTools: [], footerTools: [] }; - - const toolSlots$ = isNextChrome - ? combineLatest([ - chrome.next.globalSearch.get$(), - chrome.next.spaceSelector.get$(), - chrome.next.userMenu.get$(), - helpLinks$, - ]).pipe( - map(([searchConfig, spaceSelectorConfig, userMenuConfig, helpLinks]) => - buildToolSlots({ - globalSearch: searchConfig, - spaceSelector: spaceSelectorConfig, - userMenu: userMenuConfig, - helpLinks, - }) - ) - ) - : [emptyToolSlots]; - - return combineLatest([navState$, toolSlots$]).pipe( - map(([navState, toolSlots]) => ({ - ...navState, - toolSlots, - })) - ); - }, [chrome, basePath, helpLinks$, isNextChrome]); + }, [chrome, basePath, isNextChrome]); return useObservable(items$, null); }; diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts deleted file mode 100644 index 71122c4ad93c7..0000000000000 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_chrome_tool_slots.ts +++ /dev/null @@ -1,174 +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 { i18n } from '@kbn/i18n'; -import type { - ChromeNextGlobalSearchConfig, - ChromeNextSpaceSelectorConfig, - ChromeNextUserMenuConfig, -} from '@kbn/core-chrome-browser'; -import type { - SecondaryMenuItem, - SecondaryMenuSection, - ToolItem, - ToolSlots, -} from '@kbn/core-chrome-navigation/types'; -import type { HelpLinks, HelpMenuLinkItem } from '../../../shared/help_menu_links'; - -export interface ToolSlotsInput { - globalSearch?: ChromeNextGlobalSearchConfig; - spaceSelector?: ChromeNextSpaceSelectorConfig; - userMenu?: ChromeNextUserMenuConfig; - helpLinks: HelpLinks; -} - -export const buildToolSlots = (input: ToolSlotsInput): ToolSlots => ({ - headerTools: getHeaderTools(input.globalSearch, input.spaceSelector), - footerTools: getFooterTools(input.userMenu, input.helpLinks), -}); - -const getHeaderTools = ( - globalSearch?: ChromeNextGlobalSearchConfig, - spaceSelector?: ChromeNextSpaceSelectorConfig -): ToolItem[] => { - const tools: ToolItem[] = []; - - const spaceSelectorItem = getSpaceSelectorToolItem(spaceSelector); - if (spaceSelectorItem) { - tools.push(spaceSelectorItem); - } - - if (globalSearch) { - tools.push({ - id: 'globalSearch', - label: i18n.translate('core.chrome.projectSideNav.globalSearchLabel', { - defaultMessage: 'Search', - }), - iconType: 'search', - onClick: globalSearch.onClick, - shortcutKey: globalSearch.shortcutKey, - }); - } - - return tools; -}; - -const getSpaceSelectorToolItem = ( - config: ChromeNextSpaceSelectorConfig | undefined -): ToolItem | undefined => { - if (!config) { - return undefined; - } - - return { - id: 'spaceSelector', - label: config.label, - renderContent: () => config.renderAvatar(), - renderPopover: (closePopover) => config.renderPopover(closePopover), - popoverWidth: 360, - 'data-test-subj': 'sideNavSpaceSelector', - }; -}; - -const getFooterTools = ( - userMenu: ChromeNextUserMenuConfig | undefined, - helpLinks: HelpLinks -): ToolItem[] => { - const tools: ToolItem[] = []; - const userMenuItem = getUserMenuToolItem(userMenu); - if (userMenuItem) { - tools.push(userMenuItem); - } - const helpItem = getHelpToolItem(helpLinks); - if (helpItem) { - tools.push(helpItem); - } - return tools; -}; - -const getUserMenuToolItem = ( - config: ChromeNextUserMenuConfig | undefined -): ToolItem | undefined => { - if (!config) { - return undefined; - } - - const items: SecondaryMenuItem[] = config.items - .filter((item) => Boolean(item.href) || Boolean(item.onClick)) - .map((item) => ({ - id: item.id, - label: item.label, - ...(item.href && { href: item.href }), - ...(item.onClick && { onClick: item.onClick }), - isExternal: item.isExternal, - 'data-test-subj': item['data-test-subj'], - })); - - if (items.length === 0) { - return undefined; - } - - return { - id: 'userMenu', - label: config.label, - renderContent: () => config.renderAvatar(), - sections: [{ id: 'userMenuLinks', items }], - 'data-test-subj': 'sideNavUserMenu', - }; -}; - -const getHelpToolItem = (helpLinks: HelpLinks): ToolItem | undefined => { - const sections = buildHelpSections(helpLinks); - - if (sections.length === 0) { - return undefined; - } - - return { - id: 'help', - label: i18n.translate('core.chrome.projectSideNav.helpLabel', { - defaultMessage: 'Help', - }), - iconType: 'question', - sections, - }; -}; - -const buildHelpSections = (helpLinks: HelpLinks): SecondaryMenuSection[] => { - const mainItems = toSecondaryItems([...helpLinks.global, ...helpLinks.default]); - const extensionItems = toSecondaryItems(helpLinks.extension?.items ?? []); - - const sections: SecondaryMenuSection[] = []; - if (mainItems.length > 0) { - sections.push({ id: 'help', items: mainItems }); - } - if (extensionItems.length > 0) { - sections.push({ - id: 'helpExtension', - label: helpLinks.extension?.label, - items: extensionItems, - }); - } - return sections; -}; - -const toSecondaryItems = (items: HelpMenuLinkItem[]): SecondaryMenuItem[] => - items - .filter( - (item): item is typeof item & { label: string } => - (Boolean(item.href) || Boolean(item.onClick)) && typeof item.label === 'string' - ) - .map(({ id, label, href, onClick, 'data-test-subj': dataTestSubj, isExternal }) => ({ - id, - label, - ...(href && { href }), - ...(onClick && { onClick }), - 'data-test-subj': dataTestSubj, - isExternal, - })); diff --git a/src/core/packages/chrome/navigation/README.mdx b/src/core/packages/chrome/navigation/README.mdx index c207be1058d8b..0448bc52f49f1 100644 --- a/src/core/packages/chrome/navigation/README.mdx +++ b/src/core/packages/chrome/navigation/README.mdx @@ -64,30 +64,6 @@ const navigationItems = { ], }; -const navigationTools = { - headerTools: [ - { - id: 'search', - label: 'Search', - iconType: 'search', - onClick: () => {}, - }, - ], - footerTools: [ - { - id: 'help', - label: 'Help', - iconType: 'question', - sections: [ - { - id: 'help-links', - items: [{ id: 'docs', label: 'Documentation', href: '/docs' }], - }, - ], - }, - ], -}; - function App() { const [isCollapsed, setIsCollapsed] = useState(false); const [activeItemId, setActiveItemId] = useState('dashboard'); @@ -104,7 +80,6 @@ function App() { activeItemId={activeItemId} isCollapsed={isCollapsed} items={navigationItems} - tools={navigationTools} logo={{ label: 'Observability', id: 'observability', @@ -185,12 +160,6 @@ export const navigationItems = { }; ``` -Navigable content uses `items` only. Optional chrome controls (global search, help menus, etc.) are passed separately through `tools` as `headerTools` and `footerTools`. Those render in dedicated tool groups; `onItemClick` applies only to the logo and navigable items from `items`, not to tool triggers or links shown inside tool popovers. - -### Logo - -Pass a `SideNavLogo` object as `logo`. Set `hideLabel: true` to omit the text under the icon while the navigation is expanded; `label` is still used for the link’s accessible name and for the tooltip when the navigation is collapsed. - ## Navigation Badges Navigation badges are visual indicators used in the Kibana side navigation to highlight new or experimental features and improve feature discoverability. The system provides three types of visual indicators: diff --git a/src/core/packages/chrome/navigation/index.ts b/src/core/packages/chrome/navigation/index.ts index abf967ffa0a78..544cc0d8bb42c 100644 --- a/src/core/packages/chrome/navigation/index.ts +++ b/src/core/packages/chrome/navigation/index.ts @@ -17,6 +17,4 @@ export type { SecondaryMenuItem, SecondaryMenuSection, SideNavLogo, - ToolItem, - ToolSlots, } from './types'; diff --git a/src/core/packages/chrome/navigation/packaging/README.md b/src/core/packages/chrome/navigation/packaging/README.md index 15c5b4e433b71..3e1325301b599 100644 --- a/src/core/packages/chrome/navigation/packaging/README.md +++ b/src/core/packages/chrome/navigation/packaging/README.md @@ -27,7 +27,6 @@ import { OneNavigation } from '@kbn/one-navigation'; ``` -`items` contains navigable content only. Optional chrome controls like search and help are passed separately through `tools` (`headerTools` / `footerTools`). `onItemClick` is only invoked for navigable items and the logo. - ### Peer dependencies | Package | Version | diff --git a/src/core/packages/chrome/navigation/packaging/example/src/app.tsx b/src/core/packages/chrome/navigation/packaging/example/src/app.tsx index 739d6a2b669d9..a0b82f9218095 100644 --- a/src/core/packages/chrome/navigation/packaging/example/src/app.tsx +++ b/src/core/packages/chrome/navigation/packaging/example/src/app.tsx @@ -16,7 +16,7 @@ import { EuiProvider, EuiSpacer, EuiText, EuiCode, useEuiTheme } from '@elastic/ // @ts-expect-error — package must be built first. import { OneNavigation } from '@kbn/one-navigation'; // @ts-expect-error — package must be built first. -import type { MenuItem, SecondaryMenuItem, SideNavLogo, ToolSlots } from '@kbn/one-navigation'; +import type { MenuItem, SecondaryMenuItem, SideNavLogo } from '@kbn/one-navigation'; /** Returns a `className` that adds a divider after the Dashboard nav item. */ const useNavDividerClass = () => { @@ -108,39 +108,6 @@ const App = () => { href: '#/', }; - const tools: ToolSlots = { - headerTools: [ - { - id: 'search', - label: 'Search', - iconType: 'search', - onClick: () => { - // eslint-disable-next-line no-console - console.log('Search tool clicked'); - }, - }, - ], - footerTools: [ - { - id: 'help', - label: 'Help', - iconType: 'question', - sections: [ - { - id: 'help-links', - items: [ - { - id: 'documentation', - label: 'Documentation', - href: '#/help/documentation', - }, - ], - }, - ], - }, - ], - }; - const handleItemClick = (item: MenuItem | SecondaryMenuItem | SideNavLogo) => { // eslint-disable-next-line no-console console.log('Navigation item clicked:', item); @@ -159,7 +126,6 @@ const App = () => { & { iconType: string }; - -type ToolItemStrippedFields = 'iconType' | 'renderContent' | 'renderPopover'; - -type NormalizedSourceToolItem = Omit & { - iconType?: string; -}; -type NormalizedPackagedToolItem = Omit & { - iconType?: string; -}; - -type NormalizedSourceNavigationStructure = Omit< - SourceNavigationStructure, - 'footerItems' | 'primaryItems' -> & { - footerItems: NormalizedSourceMenuItem[]; - primaryItems: NormalizedSourceMenuItem[]; -}; -type NormalizedSourceToolSlots = Omit & { - headerTools?: NormalizedSourceToolItem[]; - footerTools?: NormalizedSourceToolItem[]; -}; -type NormalizedPackagedToolSlots = Omit & { - headerTools?: NormalizedPackagedToolItem[]; - footerTools?: NormalizedPackagedToolItem[]; -}; - -const _menuItem: PackagedMenuItem = {} as NormalizedSourceMenuItem; -const _toolItemForward: NormalizedPackagedToolItem = {} as NormalizedSourceToolItem; -const _toolItemReverse: NormalizedSourceToolItem = {} as NormalizedPackagedToolItem; - -type SourceRenderContentParam = Parameters>[0]; -type PackagedRenderContentParam = Parameters>[0]; -const _renderContentParamForward: PackagedRenderContentParam = {} as SourceRenderContentParam; -const _renderContentParamReverse: SourceRenderContentParam = {} as PackagedRenderContentParam; - -type SourceRenderPopoverParam = Parameters>[0]; -type PackagedRenderPopoverParam = Parameters>[0]; -const _renderPopoverParamForward: PackagedRenderPopoverParam = {} as SourceRenderPopoverParam; -const _renderPopoverParamReverse: SourceRenderPopoverParam = {} as PackagedRenderPopoverParam; - -const _toolSlotsForward: NormalizedPackagedToolSlots = {} as NormalizedSourceToolSlots; -const _toolSlotsReverse: NormalizedSourceToolSlots = {} as NormalizedPackagedToolSlots; -const _navigationStructure: PackagedNavigationStructure = {} as NormalizedSourceNavigationStructure; - // `NavigationProps` validation — suppressed because `MenuItem.iconType` is // intentionally simplified from `IconType` (string | ComponentClass) to `string`. // @ts-expect-error — intentional simplification; see above. @@ -117,16 +64,6 @@ void _badgeType; void _secondaryMenuItem; void _secondaryMenuSection; void _sideNavLogo; -void _menuItem; -void _toolItemForward; -void _toolItemReverse; -void _renderContentParamForward; -void _renderContentParamReverse; -void _renderPopoverParamForward; -void _renderPopoverParamReverse; -void _toolSlotsForward; -void _toolSlotsReverse; -void _navigationStructure; void _navigationProps; export const TYPE_VALIDATION_PASSED = true; diff --git a/src/core/packages/chrome/navigation/packaging/react/types.ts b/src/core/packages/chrome/navigation/packaging/react/types.ts index 312cd6b1f8561..1df7bc44dd478 100644 --- a/src/core/packages/chrome/navigation/packaging/react/types.ts +++ b/src/core/packages/chrome/navigation/packaging/react/types.ts @@ -26,11 +26,10 @@ export type BadgeType = 'beta' | 'techPreview' | 'new'; /** * A navigation item within a secondary/nested menu. - * At least one of `href` or `onClick` must be provided. */ export interface SecondaryMenuItem { /** URL or hash for navigation. */ - href?: string; + href: string; /** Unique identifier for this item. */ id: string; /** Display text for the menu item. */ @@ -41,8 +40,6 @@ export interface SecondaryMenuItem { badgeType?: BadgeType; /** If true, opens link in a new tab with an external icon. */ isExternal?: boolean; - /** Click handler for action items (e.g. opening a flyout or modal). */ - onClick?: () => void; } /** @@ -78,38 +75,10 @@ export interface MenuItem { } /** - * A chrome tool control (search, help, etc.) — not a primary navigation destination. - * - * At least one of `iconType` or `renderContent` must be provided. - * When `renderPopover` is present, it takes precedence over `sections`. - */ -export interface ToolItem { - id: string; - label: string; - iconType?: string; - renderContent?: (state: { isCollapsed: boolean }) => ReactNode; - renderPopover?: (closePopover: () => void) => ReactNode; - onClick?: () => void; - sections?: SecondaryMenuSection[]; - popoverWidth?: number | string; - badgeType?: BadgeType; - 'data-test-subj'?: string; - shortcutKey?: string; -} - -/** - * Optional tool groupings for the sidenav header and footer toolbars. - */ -export interface ToolSlots { - headerTools?: ToolItem[]; - footerTools?: ToolItem[]; -} - -/** - * Navigable sidenav structure: primary rail and footer links. + * The complete navigation structure containing primary and footer items. */ export interface NavigationStructure { - /** Items displayed in the footer navigation area. */ + /** Items displayed in the footer area of the navigation. */ footerItems: MenuItem[]; /** Items displayed in the primary/main area of the navigation. */ primaryItems: MenuItem[]; @@ -125,12 +94,8 @@ export interface SideNavLogo { href: string; /** The label for the logo, typically the product name. */ label: string; - /** When `true`, the label is not shown under the icon while expanded; still used for accessibility and collapsed tooltip. */ - hideLabel?: boolean; /** The logo type, e.g. `appObservability`, `appSecurity`, etc. */ iconType: string; - /** Optional color of the logo icon. `'default'` uses brand colors; `'text'` renders monochromatically. */ - iconColor?: 'default' | 'text'; /** Optional `data-test-subj` attribute. */ 'data-test-subj'?: string; } @@ -143,15 +108,13 @@ export interface NavigationProps { activeItemId?: string; /** Whether the navigation is collapsed. */ isCollapsed: boolean; - /** Navigable items (primary and footer links). */ + /** The navigation structure containing primary, secondary, and footer items. */ items: NavigationStructure; - /** Optional header/footer tool controls (search, help, etc.). */ - tools?: ToolSlots; /** The logo object containing the route ID, href, label, and type. */ logo: SideNavLogo; /** Required by the grid layout to set the width of the navigation slot. */ setWidth: (width: number) => void; - /** Callback fired when a navigable item or the logo is activated. */ + /** Callback fired when a navigation item is clicked. */ onItemClick?: (item: MenuItem | SecondaryMenuItem | SideNavLogo) => void; /** Callback fired when the collapse button is toggled. Omit to hide the toggle button. */ onToggleCollapsed?: (isCollapsed: boolean) => void; diff --git a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx index 4f47f883ebb5a..85f4445d7df23 100644 --- a/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx +++ b/src/core/packages/chrome/navigation/src/__stories__/navigation.stories.tsx @@ -9,16 +9,7 @@ import React, { useState } from 'react'; import type { ComponentProps } from 'react'; -import { - EuiAvatar, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiLink, - EuiSkipLink, - EuiText, - useEuiTheme, -} from '@elastic/eui'; +import { EuiSkipLink, useEuiTheme } from '@elastic/eui'; import type { UseEuiTheme } from '@elastic/eui'; import type { Meta, StoryFn, StoryObj } from '@storybook/react'; import { APP_MAIN_SCROLL_CONTAINER_ID } from '@kbn/core-chrome-layout-constants'; @@ -51,188 +42,6 @@ const styles = ({ euiTheme }: UseEuiTheme) => { type PropsAndArgs = ComponentProps; -const CHROME_TOOLS: NonNullable = { - headerTools: [ - { - id: 'search', - label: 'Search', - iconType: 'search', - onClick: () => {}, - }, - { - id: 'notifications', - label: 'Notifications', - iconType: 'bell', - onClick: () => {}, - }, - { - id: 'quick_settings', - label: 'Quick settings', - iconType: 'gear', - onClick: () => {}, - }, - ], - footerTools: [ - { - id: 'help', - label: 'Help', - iconType: 'question', - sections: [ - { - id: 'help-links', - items: [{ id: 'documentation', label: 'Documentation', href: '/help/documentation' }], - }, - ], - }, - ], -}; - -const SpaceBadge = () => ( - - D - -); - -const SPACES = [ - { id: 'default', name: 'Default', color: '#f04e98', initial: 'D' }, - { id: 'marketing', name: 'Marketing', color: '#00bfb3', initial: 'M' }, - { id: 'engineering', name: 'Engineering', color: '#6092c0', initial: 'E' }, - { id: 'sales', name: 'Sales', color: '#d6bf57', initial: 'S' }, -]; - -const SpacePicker = ({ closePopover }: { closePopover: () => void }) => ( - - - -

Spaces

-
-
- {SPACES.map((space) => ( - - - - ))} - - - { - e.preventDefault(); - closePopover(); - }} - > - Manage spaces - - -
-); - -const CUSTOM_TOOLS: NonNullable = { - headerTools: [ - { - id: 'spaces', - label: 'Current space', - renderContent: () => , - renderPopover: (closePopover) => , - popoverWidth: 360, - }, - { id: 'search', label: 'Search', iconType: 'search', onClick: () => {} }, - ], - footerTools: [ - { - id: 'help', - label: 'Help', - iconType: 'question', - sections: [ - { - id: 'help-links', - items: [{ id: 'docs', label: 'Documentation', href: '/docs' }], - }, - ], - }, - { - id: 'user', - label: 'Jane Doe', - renderContent: () => , - sections: [ - { - id: 'account', - items: [ - { id: 'profile', label: 'Profile', href: '/profile' }, - { id: 'preferences', label: 'Preferences', href: '/preferences' }, - ], - }, - { - id: 'session', - items: [{ id: 'logout', label: 'Log out', href: '/logout' }], - }, - ], - }, - ], -}; - const PreventLinkNavigation = (Story: StoryFn) => { usePreventLinkNavigation(); @@ -278,31 +87,6 @@ export const Default: StoryObj = { render: (args) => , }; -export const WithTools: StoryObj = { - name: 'With header and footer tools', - decorators: [ - (Story) => { - return ( - <> - - - - ); - }, - ], - args: { - tools: CHROME_TOOLS, - logo: { - id: 'observability', - href: LOGO.href, - label: LOGO.label, - iconType: LOGO.iconType, - hideLabel: true, - }, - }, - render: (args) => , -}; - export const WithMinimalItems: StoryObj = { name: 'Navigation with Minimal Items', decorators: [ @@ -365,31 +149,6 @@ export const WithManyItems: StoryObj = { render: (args) => , }; -export const WithCustomToolItems: StoryObj = { - name: 'With custom tool items (avatar & space badge)', - decorators: [ - (Story) => { - return ( - <> - - - - ); - }, - ], - args: { - tools: CUSTOM_TOOLS, - logo: { - id: 'observability', - href: LOGO.href, - label: LOGO.label, - iconType: LOGO.iconType, - hideLabel: true, - }, - }, - render: (args) => , -}; - export const WithinLayout: StoryObj = { name: 'Navigation within Layout', render: (args) => , diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap index 75c8a9d96ae83..b1a62356ca22f 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/both_modes.test.tsx.snap @@ -11,37 +11,33 @@ exports[`Both modes should render the side navigation 1`] = ` class="kbnChromeNav-root css-vka7tb-getNavWrapperStyles" > +
+ Solution +
+

You are in the main navigation footer menu. Use Up and Down arrow keys to navigate the menu.

-
- -
+
@@ -384,6 +376,7 @@ exports[`Both modes should render the side navigation 1`] = ` aria-pressed="true" class="euiButtonIcon emotion-euiButtonIcon-s-empty-text-sideNavCollapseButton" data-test-subj="sideNavCollapseButton" + tabindex="-1" type="button" >
-
+ diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap index c4de9479fb1f6..bd3d8313bf8c7 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/collapsed_mode.test.tsx.snap @@ -10,44 +10,40 @@ exports[`Collapsed mode should render the side navigation 1`] = `
- +
+
+ Solution +
+ + + - -
+
@@ -399,6 +391,7 @@ exports[`Collapsed mode should render the side navigation 1`] = ` aria-pressed="false" class="euiButtonIcon emotion-euiButtonIcon-s-empty-text-sideNavCollapseButton" data-test-subj="sideNavCollapseButton" + tabindex="-1" type="button" >
-
+ diff --git a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap index 2dd837f864d6f..c61ec302b070b 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap +++ b/src/core/packages/chrome/navigation/src/__tests__/__snapshots__/expanded_mode.test.tsx.snap @@ -11,37 +11,33 @@ exports[`Expanded mode should render the side navigation 1`] = ` class="kbnChromeNav-root css-vka7tb-getNavWrapperStyles" > +
+ Solution +
+

You are in the main navigation footer menu. Use Up and Down arrow keys to navigate the menu.

- -
+
@@ -384,6 +376,7 @@ exports[`Expanded mode should render the side navigation 1`] = ` aria-pressed="true" class="euiButtonIcon emotion-euiButtonIcon-s-empty-text-sideNavCollapseButton" data-test-subj="sideNavCollapseButton" + tabindex="-1" type="button" >
-
+ diff --git a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx index fe9cd020b085b..a6d9f340902b1 100644 --- a/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx +++ b/src/core/packages/chrome/navigation/src/__tests__/both_modes.test.tsx @@ -1251,7 +1251,6 @@ describe('Both modes', () => { const serviceInventoryLink = within(sidePanel).getByTestId( sidePanelItemId('service-inventory') ); - const collapseButton = screen.getByTestId('sideNavCollapseButton'); act(() => { solutionLogo.focus(); @@ -1269,10 +1268,7 @@ describe('Both modes', () => { expect(gettingStartedLink).toHaveFocus(); - await user.tab(); - - expect(collapseButton).toHaveFocus(); - + // Tab to side panel - should land on first focusable item (Service inventory) await user.tab(); expect(serviceInventoryLink).toHaveFocus(); diff --git a/src/core/packages/chrome/navigation/src/components/footer/index.tsx b/src/core/packages/chrome/navigation/src/components/footer/index.tsx index 89979aca3f2d3..a6e64bbcfd96f 100644 --- a/src/core/packages/chrome/navigation/src/components/footer/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/footer/index.tsx @@ -7,8 +7,131 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { FooterNav } from './nav'; -export type { FooterNavProps, FooterNavIds, FooterNavChildren } from './nav'; -export { FooterToolbar } from './toolbar'; -export type { FooterToolbarProps } from './toolbar'; -export { FooterItem } from './item'; +import React, { forwardRef, useMemo } from 'react'; +import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { + EuiHorizontalRule, + EuiScreenReaderOnly, + useEuiTheme, + useGeneratedHtmlId, + type UseEuiTheme, +} from '@elastic/eui'; + +import { FooterItem } from './item'; +import { getFocusableElements } from '../../utils/get_focusable_elements'; +import { handleRovingIndex } from '../../utils/handle_roving_index'; +import { updateTabIndices } from '../../utils/update_tab_indices'; +import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; +import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; + +const getFooterWrapperStyles = (euiThemeContext: UseEuiTheme, isCollapsed: boolean) => { + const { euiTheme: theme } = euiThemeContext; + return { + root: css` + align-items: center; + display: flex; + position: relative; + flex-direction: column; + gap: ${theme.size.xs}; + justify-content: center; + padding-top: ${isCollapsed ? theme.size.s : theme.size.m}; + + ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} + `, + collapseDivider: css` + position: relative; + background-color: transparent; + + ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} + `, + }; +}; + +export interface FooterIds { + footerNavigationInstructionsId: string; +} + +export type FooterChildren = ReactNode | ((ids: FooterIds) => ReactNode); + +export interface FooterProps { + children: FooterChildren; + isCollapsed: boolean; + collapseButton?: ReactNode; +} + +interface FooterComponent + extends ForwardRefExoticComponent> { + Item: typeof FooterItem; +} + +const FooterBase = forwardRef( + ({ children, isCollapsed, collapseButton }, ref) => { + const euiThemeContext = useEuiTheme(); + const footerNavigationInstructionsId = useGeneratedHtmlId({ + prefix: 'footer-navigation-instructions', + }); + + const handleRef = (node: HTMLElement | null) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + + if (node) { + const elements = getFocusableElements(node); + updateTabIndices(elements); + } + }; + + const wrapperStyles = useMemo( + () => getFooterWrapperStyles(euiThemeContext, isCollapsed), + [euiThemeContext, isCollapsed] + ); + + const renderChildren = () => { + if (typeof children === 'function') { + return children({ footerNavigationInstructionsId }); + } + return children; + }; + + return ( + <> + +

+ {i18n.translate('core.ui.chrome.sideNavigation.footerInstructions', { + defaultMessage: + 'You are in the main navigation footer menu. Use Up and Down arrow keys to navigate the menu.', + })} +

+
+ {/* The footer itself is not interactive but the children are */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +
+ {renderChildren()} + {collapseButton && ( + <> + + {collapseButton} + + )} +
+ + ); + } +); + +export const Footer = Object.assign(FooterBase, { + Item: FooterItem, +}) satisfies FooterComponent; diff --git a/src/core/packages/chrome/navigation/src/components/footer/item.tsx b/src/core/packages/chrome/navigation/src/components/footer/item.tsx index 3b1747ba58b5e..984aed8939862 100644 --- a/src/core/packages/chrome/navigation/src/components/footer/item.tsx +++ b/src/core/packages/chrome/navigation/src/components/footer/item.tsx @@ -7,31 +7,135 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { forwardRef } from 'react'; -import type { KeyboardEvent, Ref } from 'react'; -import type { EuiButtonIconProps } from '@elastic/eui'; +import React, { Suspense, forwardRef } from 'react'; +import type { KeyboardEvent, ForwardedRef, ComponentProps } from 'react'; +import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import type { EuiButtonIconProps, IconType } from '@elastic/eui'; +import { css } from '@emotion/react'; import type { MenuItem } from '../../../types'; -import { IconButton } from '../icon_button'; -import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; +import { BetaBadge } from '../beta_badge'; +import { NAVIGATION_SELECTOR_PREFIX, TOOLTIP_OFFSET } from '../../constants'; +import { focusMainContent } from '../../utils/focus_main_content'; +import { useHighContrastModeStyles } from '../../hooks/use_high_contrast_mode_styles'; +import { useTooltip } from '../../hooks/use_tooltip'; +import { NewItemIndicator } from '../new_item_indicator'; -export interface FooterItemProps - extends Omit, - Omit { +export interface FooterItemProps extends Omit, MenuItem { hasContent?: boolean; + iconType: IconType; isCurrent?: boolean; isHighlighted: boolean; isNew: boolean; + label: string; onClick?: () => void; onKeyDown?: (e: KeyboardEvent) => void; } +/** + * A footer item that leverages the "Toggle button" pattern from EUI. + * + * @see {@link https://eui.elastic.co/docs/components/navigation/buttons/button/#toggle-button} + */ export const FooterItem = forwardRef( - ({ id, ...props }, ref) => ( - } - data-test-subj={`${NAVIGATION_SELECTOR_PREFIX}-footerItem-${id}`} - {...props} - /> - ) + ( + { badgeType, hasContent, iconType, id, isCurrent, isHighlighted, isNew, label, ...props }, + ref: ForwardedRef + ) => { + const { euiTheme } = useEuiTheme(); + const { tooltipRef, handleMouseOut } = useTooltip(); + const highContrastModeStyles = useHighContrastModeStyles(); + + const handleFooterItemKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + // Required for entering the popover with Enter or Space key + // Otherwise the navigation happens immediately + e.preventDefault(); + focusMainContent(); + } + }; + + const wrapperStyles = css` + display: flex; + justify-content: center; + width: 100%; + `; + + const buttonStyles = css` + --high-contrast-hover-indicator-color: ${isHighlighted + ? euiTheme.colors.textPrimary + : euiTheme.colors.textParagraph}; + ${highContrastModeStyles} + `; + + const buttonWrapperStyles = css` + position: relative; + display: inline-flex; + `; + + const footerItemTestSubj = `${NAVIGATION_SELECTOR_PREFIX}-footerItem-${id}`; + + const buttonProps: ComponentProps & { + 'data-highlighted': string; + 'data-menu-item': string; + } = { + 'aria-current': isCurrent ? 'page' : undefined, + 'aria-label': label, + buttonRef: ref, + color: isHighlighted ? 'primary' : 'text', + 'data-highlighted': isHighlighted ? 'true' : 'false', + 'data-test-subj': footerItemTestSubj, + 'data-menu-item': 'true', + display: isHighlighted ? 'base' : 'empty', + iconType: 'empty', // `iconType` is passed in Suspense below + onKeyDown: handleFooterItemKeyDown, + size: 's', + css: buttonStyles, + ...props, + }; + + const menuItem = ( +
+ }> + + + {isNew && } +
+ ); + + if (!hasContent) { + const tooltipStyles = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + `; + const tooltipContent = badgeType ? ( + + {label} + + + ) : ( + label + ); + + return ( + + {menuItem} + + ); + } + + return
{menuItem}
; + } ); diff --git a/src/core/packages/chrome/navigation/src/components/footer/nav.tsx b/src/core/packages/chrome/navigation/src/components/footer/nav.tsx deleted file mode 100644 index 19808bac162be..0000000000000 --- a/src/core/packages/chrome/navigation/src/components/footer/nav.tsx +++ /dev/null @@ -1,118 +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 React, { forwardRef, useMemo } from 'react'; -import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { - EuiScreenReaderOnly, - useEuiTheme, - useGeneratedHtmlId, - type UseEuiTheme, -} from '@elastic/eui'; - -import { FooterItem } from './item'; -import { getFocusableElements } from '../../utils/get_focusable_elements'; -import { handleRovingIndex } from '../../utils/handle_roving_index'; -import { updateTabIndices } from '../../utils/update_tab_indices'; -import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; -import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; - -const getFooterNavWrapperStyles = (euiThemeContext: UseEuiTheme, isCollapsed: boolean) => { - const { euiTheme: theme } = euiThemeContext; - return css` - align-items: center; - display: flex; - position: relative; - flex-direction: column; - gap: ${theme.size.xs}; - justify-content: center; - padding-top: ${isCollapsed ? theme.size.s : theme.size.m}; - - ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} - `; -}; - -export interface FooterNavIds { - footerNavigationInstructionsId: string; -} - -export type FooterNavChildren = ReactNode | ((ids: FooterNavIds) => ReactNode); - -export interface FooterNavProps { - children: FooterNavChildren; - isCollapsed: boolean; -} - -interface FooterNavComponent - extends ForwardRefExoticComponent> { - Item: typeof FooterItem; -} - -const FooterNavBase = forwardRef(({ children, isCollapsed }, ref) => { - const euiThemeContext = useEuiTheme(); - const footerNavigationInstructionsId = useGeneratedHtmlId({ - prefix: 'footer-navigation-instructions', - }); - - const handleRef = (node: HTMLElement | null) => { - if (typeof ref === 'function') { - ref(node); - } else if (ref) { - ref.current = node; - } - - if (node) { - const elements = getFocusableElements(node); - updateTabIndices(elements); - } - }; - - const wrapperStyles = useMemo( - () => getFooterNavWrapperStyles(euiThemeContext, isCollapsed), - [euiThemeContext, isCollapsed] - ); - - const renderChildren = () => { - if (typeof children === 'function') { - return children({ footerNavigationInstructionsId }); - } - return children; - }; - - return ( - <> - -

- {i18n.translate('core.ui.chrome.sideNavigation.footerInstructions', { - defaultMessage: - 'You are in the main navigation footer menu. Use Up and Down arrow keys to navigate the menu.', - })} -

-
- {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} - - - ); -}); - -export const FooterNav = Object.assign(FooterNavBase, { - Item: FooterItem, -}) satisfies FooterNavComponent; diff --git a/src/core/packages/chrome/navigation/src/components/footer/toolbar.tsx b/src/core/packages/chrome/navigation/src/components/footer/toolbar.tsx deleted file mode 100644 index ec5ee70b40e35..0000000000000 --- a/src/core/packages/chrome/navigation/src/components/footer/toolbar.tsx +++ /dev/null @@ -1,73 +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 React, { forwardRef, useMemo } from 'react'; -import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; -import { css } from '@emotion/react'; -import { useEuiTheme, type UseEuiTheme } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -import { ToolItem } from '../tool_item'; -import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; -import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; - -const getFooterToolbarWrapperStyles = (euiThemeContext: UseEuiTheme, isCollapsed: boolean) => { - const { euiTheme: theme } = euiThemeContext; - return css` - align-items: center; - display: flex; - flex-direction: column; - gap: ${theme.size.xs}; - justify-content: center; - padding-top: ${isCollapsed ? theme.size.s : theme.size.m}; - position: relative; - width: 100%; - - ${getHighContrastSeparator(euiThemeContext, { side: 'top' })} - `; -}; - -export interface FooterToolbarProps { - children?: ReactNode; - isCollapsed: boolean; -} - -interface FooterToolbarComponent - extends ForwardRefExoticComponent> { - Item: typeof ToolItem; -} - -const FooterToolbarBase = forwardRef( - ({ children, isCollapsed }, ref) => { - const euiThemeContext = useEuiTheme(); - - const wrapperStyles = useMemo( - () => getFooterToolbarWrapperStyles(euiThemeContext, isCollapsed), - [euiThemeContext, isCollapsed] - ); - - return ( -
- {children} -
- ); - } -); - -export const FooterToolbar = Object.assign(FooterToolbarBase, { - Item: ToolItem, -}) satisfies FooterToolbarComponent; diff --git a/src/core/packages/chrome/navigation/src/components/header/index.tsx b/src/core/packages/chrome/navigation/src/components/header/index.tsx deleted file mode 100644 index d97146f9f8424..0000000000000 --- a/src/core/packages/chrome/navigation/src/components/header/index.tsx +++ /dev/null @@ -1,52 +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 React, { forwardRef } from 'react'; -import type { ForwardRefExoticComponent, ReactNode, RefAttributes } from 'react'; -import { css } from '@emotion/react'; -import { i18n } from '@kbn/i18n'; -import { useEuiTheme } from '@elastic/eui'; - -import { ToolItem } from '../tool_item'; -import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; - -export interface HeaderToolbarProps { - children: ReactNode; -} - -export const HeaderToolbar = forwardRef(({ children }, ref) => { - const { euiTheme } = useEuiTheme(); - - const wrapperStyles = css` - align-items: center; - display: flex; - flex-direction: column; - gap: ${euiTheme.size.xs}; - justify-content: center; - width: 100%; - `; - - return ( -
- {children} -
- ); -}) as ForwardRefExoticComponent> & { - Item: typeof ToolItem; -}; - -HeaderToolbar.Item = ToolItem; diff --git a/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx b/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx deleted file mode 100644 index 505bd734c1750..0000000000000 --- a/src/core/packages/chrome/navigation/src/components/icon_button/index.tsx +++ /dev/null @@ -1,194 +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 React, { Suspense, forwardRef } from 'react'; -import type { KeyboardEvent, ReactNode, ComponentProps, Ref } from 'react'; -import { EuiButtonIcon, EuiToolTip, useEuiTheme } from '@elastic/eui'; -import type { EuiButtonIconProps, IconType } from '@elastic/eui'; -import { css } from '@emotion/react'; - -import type { BadgeType } from '../../../types'; -import { BetaBadge } from '../beta_badge'; -import { TOOLTIP_OFFSET } from '../../constants'; -import { useHighContrastModeStyles } from '../../hooks/use_high_contrast_mode_styles'; -import { useTooltip } from '../../hooks/use_tooltip'; -import { NewItemIndicator } from '../new_item_indicator'; - -export interface IconButtonProps - extends Omit { - badgeType?: BadgeType; - hasContent?: boolean; - iconType?: IconType; - isCollapsed?: boolean; - isCurrent?: boolean; - isHighlighted: boolean; - isNew: boolean; - label: string; - onClick?: () => void; - onKeyDown?: (e: KeyboardEvent) => void; - renderContent?: (state: { isCollapsed: boolean }) => ReactNode; -} - -export const IconButton = forwardRef( - ( - { - badgeType, - hasContent, - iconType, - isCollapsed, - isCurrent, - isHighlighted, - isNew, - label, - renderContent, - ...props - }, - ref - ) => { - const buttonRef = ref as Ref; - const { euiTheme } = useEuiTheme(); - const { tooltipRef, handleMouseOut } = useTooltip(); - const highContrastModeStyles = useHighContrastModeStyles(); - - const wrapperStyles = css` - display: flex; - justify-content: center; - width: 100%; - `; - - const buttonStyles = css` - --high-contrast-hover-indicator-color: ${isHighlighted - ? euiTheme.colors.textPrimary - : euiTheme.colors.textParagraph}; - ${highContrastModeStyles} - `; - - const buttonWrapperStyles = css` - position: relative; - display: inline-flex; - `; - - let item: JSX.Element; - - if (renderContent) { - const shellStyles = css` - display: inline-flex; - align-items: center; - justify-content: center; - height: ${euiTheme.size.xl}; - width: ${euiTheme.size.xl}; - border: none; - border-radius: ${euiTheme.border.radius.small}; - background: ${isHighlighted ? euiTheme.colors.backgroundBasePrimary : 'transparent'}; - cursor: pointer; - padding: 0; - color: ${isHighlighted ? euiTheme.colors.textPrimary : euiTheme.colors.textParagraph}; - position: relative; - overflow: hidden; - &:hover::before { - content: ''; - position: absolute; - z-index: 0; - inset: 0; - background-color: ${euiTheme.components.buttons.backgroundEmptyTextHover}; - pointer-events: none; - } - &:active::before { - content: ''; - position: absolute; - inset: 0; - background-color: ${euiTheme.components.buttons.backgroundEmptyTextActive}; - } - &:focus-visible { - outline: ${euiTheme.focus.width} solid ${euiTheme.focus.color}; - outline-offset: 2px; - } - `; - - item = ( -
- - {isNew && } -
- ); - } else { - const euiButtonProps: ComponentProps & { - 'data-highlighted': string; - 'data-menu-item': string; - } = { - 'aria-current': isCurrent ? 'page' : undefined, - 'aria-label': label, - buttonRef, - color: isHighlighted ? 'primary' : 'text', - 'data-highlighted': isHighlighted ? 'true' : 'false', - 'data-menu-item': 'true', - display: isHighlighted ? 'base' : 'empty', - iconType: 'empty', - size: 's', - css: buttonStyles, - ...props, - }; - - item = ( -
- }> - - - {isNew && } -
- ); - } - - if (!hasContent) { - const tooltipStyles = css` - display: flex; - align-items: center; - gap: ${euiTheme.size.s}; - `; - const tooltipContent = badgeType ? ( - - {label} - - - ) : ( - label - ); - - return ( - - {item} - - ); - } - - return
{item}
; - } -); diff --git a/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx b/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx index 47aceca945318..2082d94cdb256 100644 --- a/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/menu_item/index.tsx @@ -20,7 +20,6 @@ import { NewItemIndicator } from '../new_item_indicator'; interface MenuItemBaseProps { children: ReactNode; iconType: IconType; - iconColor?: 'default' | 'text'; id?: string; isCurrent?: boolean; isHighlighted: boolean; @@ -55,7 +54,6 @@ export const MenuItem = forwardRef
}> - + {isNew && }
diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 9cfe7763d287a..9a322be8e69d1 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -7,26 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { - useMemo, - useState, - type ForwardRefExoticComponent, - type ReactNode, - type RefAttributes, -} from 'react'; +import React, { useState, type ReactNode } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { useEuiTheme, useIsWithinBreakpoints, type UseEuiTheme } from '@elastic/eui'; +import { useIsWithinBreakpoints } from '@elastic/eui'; -import type { - MenuItem, - NavigationStructure, - SecondaryMenuItem, - SideNavLogo, - ToolItem, - ToolSlots, -} from '../../types'; +import type { NavigationStructure, SideNavLogo, MenuItem, SecondaryMenuItem } from '../../types'; import { MAIN_PANEL_ID, MAX_FOOTER_ITEMS, @@ -34,7 +21,6 @@ import { NAVIGATION_ROOT_SELECTOR, NAVIGATION_SELECTOR_PREFIX, } from '../constants'; -import type { ToolItemProps } from './tool_item'; import { SideNav } from './side_nav'; import { SideNavCollapseButton } from './collapse_button'; import { focusMainContent } from '../utils/focus_main_content'; @@ -43,33 +29,11 @@ import { useLayoutWidth } from '../hooks/use_layout_width'; import { useNavigation } from '../hooks/use_navigation'; import { useNewItems } from '../hooks/use_new_items'; import { useResponsiveMenu } from '../hooks/use_responsive_menu'; -import { getHighContrastSeparator } from '../hooks/use_high_contrast_mode_styles'; -import { useToolShortcuts } from '../hooks/use_tool_shortcuts'; - -const EMPTY_TOOL_ITEMS: ToolItem[] = []; const navigationWrapperStyles = css` display: flex; `; -const getTopSectionStyles = (euiThemeContext: UseEuiTheme) => { - const { euiTheme } = euiThemeContext; - return css` - align-items: center; - display: flex; - flex-direction: column; - gap: ${euiTheme.size.s}; - justify-content: center; - padding-bottom: ${euiTheme.size.s}; - position: relative; - width: 100%; - - ${getHighContrastSeparator(euiThemeContext)} - `; -}; - -type SelectableNavigationItem = MenuItem | SecondaryMenuItem | SideNavLogo; - export interface NavigationProps { /** * The active path for the navigation, used for highlighting the current item. @@ -80,13 +44,9 @@ export interface NavigationProps { */ isCollapsed: boolean; /** - * The navigation structure containing primary and footer navigable items. + * The navigation structure containing primary, secondary, and footer items. */ items: NavigationStructure; - /** - * Optional chrome tools (search, help, etc.) rendered in header and footer toolbars. - */ - tools?: ToolSlots; /** * The logo object containing the route ID, href, label, and type. */ @@ -96,9 +56,9 @@ export interface NavigationProps { */ setWidth: (width: number) => void; /** - * (optional) Callback fired when a navigable item (or logo) is activated. Not called for tool controls. + * (optional) Callback fired when a navigation item is clicked. */ - onItemClick?: (item: SelectableNavigationItem) => void; + onItemClick?: (item: MenuItem | SecondaryMenuItem | SideNavLogo) => void; /** * Callback fired when the collapse button is toggled. * @@ -120,7 +80,6 @@ export const Navigation = ({ activeItemId, isCollapsed: isCollapsedProp, items, - tools, logo, onItemClick, onToggleCollapsed, @@ -128,15 +87,13 @@ export const Navigation = ({ sidePanelFooter, ...rest }: NavigationProps) => { - const euiThemeContext = useEuiTheme(); const forcedCollapsed = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = forcedCollapsed || isCollapsedProp; const popoverItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverItem`; const popoverFooterItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverFooterItem`; - const popoverFooterToolPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverFooterTool`; - const popoverHeaderToolPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverHeaderTool`; const sidePanelItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-sidePanelItem`; const moreMenuTriggerTestSubj = `${NAVIGATION_SELECTOR_PREFIX}-moreMenuTrigger`; + const { actualActiveItemId, visuallyActivePageId, @@ -161,21 +118,11 @@ export const Navigation = ({ useLayoutWidth({ isCollapsed, isSidePanelOpen, setWidth }); - const collapseToggle = onToggleCollapsed && !forcedCollapsed ? onToggleCollapsed : undefined; - - const headerTools = tools?.headerTools ?? EMPTY_TOOL_ITEMS; - const footerTools = tools?.footerTools ?? EMPTY_TOOL_ITEMS; - const hasHeaderTools = headerTools.length > 0; - const hasFooterTools = footerTools.length > 0; - const showFooterToolbar = hasFooterTools || Boolean(collapseToggle); - - const allTools = useMemo(() => [...headerTools, ...footerTools], [headerTools, footerTools]); - useToolShortcuts({ tools: allTools }); - - const topSectionStyles = useMemo(() => getTopSectionStyles(euiThemeContext), [euiThemeContext]); - - const footerNavItems = items.footerItems.slice(0, MAX_FOOTER_ITEMS); - const hasFooterNavItems = footerNavItems.length > 0; + // Create the collapse button if a toggle callback is provided or if the navigation is not forced to be collapsed (e.g. on mobile) + const collapseButton = + onToggleCollapsed && !forcedCollapsed ? ( + + ) : null; return (
-
- onItemClick?.(logo)} - {...logo} - /> - {hasHeaderTools && ( - - {renderToolItems({ - anchorPosition: 'rightDown', - items: headerTools, - itemComponent: SideNav.HeaderToolbar.Item, - isAnyPopoverLocked, - isCollapsed, - popoverItemPrefix: popoverHeaderToolPrefix, - })} - - )} -
+ onItemClick?.(logo)} + {...logo} + /> {({ mainNavigationInstructionsId }) => ( @@ -255,9 +188,8 @@ export const Navigation = ({ const isFirstSubItem = sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; const subItemAriaDescribedBy = isFirstSubItem - ? ids.popoverNavigationInstructionsId + ? ids?.popoverNavigationInstructionsId : undefined; - const { onClick: subItemOnClick, ...subItemRest } = subItem; return ( { - subItemOnClick?.(); onItemClick?.(subItem); if (subItem.href) { closePopover(); } }} testSubjPrefix={popoverItemPrefix} - {...subItemRest} + {...subItem} > {subItem.label} @@ -380,26 +311,22 @@ export const Navigation = ({ key={section.id} label={section.label} > - {section.items.map((subItem) => { - const { onClick: subItemOnClick, ...subItemRest } = subItem; - return ( - { - subItemOnClick?.(); - onItemClick?.(subItem); - closePopover(); - focusMainContent(); - }} - {...subItemRest} - > - {subItem.label} - - ); - })} + {section.items.map((subItem) => ( + { + onItemClick?.(subItem); + closePopover(); + focusMainContent(); + }} + {...subItem} + > + {subItem.label} + + ))} ))} @@ -414,44 +341,84 @@ export const Navigation = ({ )} - {hasFooterNavItems && ( - - {({ footerNavigationInstructionsId }) => - renderFooterNavItems({ - items: footerNavItems, - itemComponent: SideNav.FooterNav.Item, - navigationInstructionsId: footerNavigationInstructionsId, - openerNode, - isCollapsed, - isAnyPopoverLocked, - visuallyActivePageId, - visuallyActiveSubpageId, - actualActiveItemId, - onItemClick, - getIsNewPrimary, - getIsNewSecondary, - popoverItemPrefix: popoverFooterItemPrefix, - }) - } - - )} + + {({ footerNavigationInstructionsId }) => ( + <> + {items.footerItems.slice(0, MAX_FOOTER_ITEMS).map((item, index) => { + const { sections, ...itemProps } = item; + const isFirstItem = index === 0; + const ariaDescribedBy = isFirstItem ? footerNavigationInstructionsId : undefined; + + return ( + onItemClick?.(item)} + {...itemProps} + /> + } + > + {(closePopover, ids) => ( + + {sections?.map((section, sectionIndex) => { + const firstNonEmptySectionIndex = item.sections?.findIndex( + (s) => s.items.length > 0 + ); + return ( + + {section.items.map((subItem, subItemIndex) => { + const isFirstSubItem = + sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; + const subItemAriaDescribedBy = isFirstSubItem + ? ids?.popoverNavigationInstructionsId + : undefined; - {showFooterToolbar && ( - - {hasFooterTools && - renderToolItems({ - anchorPosition: 'rightUp', - items: footerTools, - itemComponent: SideNav.FooterToolbar.Item, - isAnyPopoverLocked, - isCollapsed, - popoverItemPrefix: popoverFooterToolPrefix, + return ( + { + onItemClick?.(subItem); + if (subItem.href) { + closePopover(); + } + }} + {...subItem} + testSubjPrefix={popoverFooterItemPrefix} + > + {subItem.label} + + ); + })} + + ); + })} + + )} + + ); })} - {collapseToggle && ( - - )} - - )} + + )} +
{isSidePanelOpen && openerNode && ( @@ -476,7 +443,6 @@ export const Navigation = ({ const ariaDescribedBy = isFirstItem ? secondaryNavigationInstructionsId : undefined; - const { onClick: subItemOnClick, ...subItemRest } = subItem; return ( { - subItemOnClick?.(); - onItemClick?.(subItem); - }} + onClick={() => onItemClick?.(subItem)} testSubjPrefix={sidePanelItemPrefix} - {...subItemRest} + {...subItem} > {subItem.label} @@ -506,208 +469,3 @@ export const Navigation = ({
); }; - -interface RenderFooterNavItemsArgs { - actualActiveItemId?: string; - getIsNewPrimary: (itemId: string) => boolean; - getIsNewSecondary: (itemId: string) => boolean; - isAnyPopoverLocked: boolean; - isCollapsed: boolean; - itemComponent: typeof SideNav.FooterNav.Item; - items: MenuItem[]; - navigationInstructionsId: string | undefined; - onItemClick?: (item: SelectableNavigationItem) => void; - openerNode: MenuItem | null; - popoverItemPrefix: string; - visuallyActivePageId?: string; - visuallyActiveSubpageId?: string; -} - -const renderFooterNavItems = ({ - actualActiveItemId, - getIsNewPrimary, - getIsNewSecondary, - isAnyPopoverLocked, - isCollapsed, - itemComponent: ItemComponent, - items, - navigationInstructionsId, - onItemClick, - openerNode, - popoverItemPrefix, - visuallyActivePageId, - visuallyActiveSubpageId, -}: RenderFooterNavItemsArgs) => - items.map((item, index) => { - const ariaDescribedBy = index === 0 ? navigationInstructionsId : undefined; - const { sections, ...itemProps } = item; - - return ( - onItemClick?.(item)} - {...itemProps} - /> - } - > - {(closePopover, ids) => { - const firstNonEmptySectionIndex = sections?.findIndex( - (section) => section.items.length > 0 - ); - return ( - - {sections?.map((section, sectionIndex) => ( - - {section.items.map((subItem, subItemIndex) => { - const isFirstSubItem = - sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; - const subItemAriaDescribedBy = isFirstSubItem - ? ids.popoverNavigationInstructionsId - : undefined; - const { onClick: subItemOnClick, ...subItemRest } = subItem; - - return ( - { - subItemOnClick?.(); - onItemClick?.(subItem); - if (subItem.href) { - closePopover(); - } - }} - {...subItemRest} - testSubjPrefix={popoverItemPrefix} - > - {subItem.label} - - ); - })} - - ))} - - ); - }} - - ); - }); - -interface RenderToolItemsArgs { - anchorPosition?: 'rightUp' | 'rightDown'; - isAnyPopoverLocked: boolean; - isCollapsed: boolean; - itemComponent: ForwardRefExoticComponent>; - items: ToolItem[]; - popoverItemPrefix: string; -} - -const renderToolItems = ({ - anchorPosition, - isAnyPopoverLocked, - isCollapsed, - itemComponent: ItemComponent, - items, - popoverItemPrefix, -}: RenderToolItemsArgs) => - items.map((item) => { - const { - onClick: itemOnClick, - sections, - renderContent, - renderPopover, - popoverWidth, - shortcutKey, - ...itemProps - } = item; - const hasPopoverContent = getHasSubmenu(item); - return ( - { - itemOnClick?.(); - }} - {...itemProps} - /> - } - > - {(closePopover, ids) => { - if (renderPopover) { - return renderPopover(closePopover); - } - - const firstNonEmptySectionIndex = sections?.findIndex( - (section) => section.items.length > 0 - ); - return ( - - {sections?.map((section, sectionIndex) => ( - - {section.items.map((subItem, subItemIndex) => { - const isFirstSubItem = - sectionIndex === firstNonEmptySectionIndex && subItemIndex === 0; - const subItemAriaDescribedBy = isFirstSubItem - ? ids.popoverNavigationInstructionsId - : undefined; - const { onClick: subItemOnClick, ...subItemRest } = subItem; - - return ( - { - subItemOnClick?.(); - closePopover(); - }} - {...subItemRest} - testSubjPrefix={popoverItemPrefix} - > - {subItem.label} - - ); - })} - - ))} - - ); - }} - - ); - }); diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx index 6eb31ffd09d06..f9a15f38e5b47 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/index.tsx @@ -13,8 +13,7 @@ import { css } from '@emotion/react'; import { useEuiTheme, type UseEuiTheme } from '@elastic/eui'; import { COLLAPSED_WIDTH, EXPANDED_WIDTH } from '../../hooks/use_layout_width'; -import { FooterNav, FooterToolbar } from '../footer'; -import { HeaderToolbar } from '../header'; +import { Footer } from '../footer'; import { Logo } from './logo'; import { NestedSecondaryMenu } from '../nested_secondary_menu'; import { Popover } from './popover'; @@ -41,24 +40,21 @@ export interface SideNavProps { interface SideNavComponent extends FC { Logo: typeof Logo; - HeaderToolbar: typeof HeaderToolbar; PrimaryMenu: typeof PrimaryMenu; Popover: typeof Popover; SecondaryMenu: typeof SecondaryMenu; NestedSecondaryMenu: typeof NestedSecondaryMenu; - FooterNav: typeof FooterNav; - FooterToolbar: typeof FooterToolbar; + Footer: typeof Footer; SidePanel: typeof SidePanel; } /** * A wrapper component for the side navigation that encapsulates: * - the logo, - * - header and footer toolbars, * - the primary menu, * - the secondary menu used in the popover and in the side panel, * - the nested secondary menu used in the "More" menu, - * - the footer navigation and toolbar, + * - the footer, * - the side panel. */ export const SideNav: SideNavComponent = ({ children, isCollapsed }) => { @@ -77,11 +73,9 @@ export const SideNav: SideNavComponent = ({ children, isCollapsed }) => { }; SideNav.Logo = Logo; -SideNav.HeaderToolbar = HeaderToolbar; SideNav.PrimaryMenu = PrimaryMenu; SideNav.Popover = Popover; SideNav.SecondaryMenu = SecondaryMenu; SideNav.NestedSecondaryMenu = NestedSecondaryMenu; -SideNav.FooterNav = FooterNav; -SideNav.FooterToolbar = FooterToolbar; +SideNav.Footer = Footer; SideNav.SidePanel = SidePanel; diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx index 8b5008a3f903e..c9edaefa15c88 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/logo.tsx @@ -17,6 +17,7 @@ import type { SideNavLogo } from '../../../types'; import { MenuItem } from '../menu_item'; import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; import { useTooltip } from '../../hooks/use_tooltip'; +import { getHighContrastSeparator } from '../../hooks/use_high_contrast_mode_styles'; export interface LogoProps extends Omit, 'onClick'>, SideNavLogo { id: string; @@ -30,16 +31,30 @@ export const Logo = ({ isCollapsed, isCurrent, isHighlighted, - hideLabel, label, ...props }: LogoProps): JSX.Element => { - const { euiTheme } = useEuiTheme(); + const euiThemeContext = useEuiTheme(); + const { euiTheme } = euiThemeContext; const { tooltipRef, handleMouseOut } = useTooltip(); + /** + * **Icon size** + * + * In Figma, the logo icon is 20x20. + * `EuiIcon` supports `l` which is 24x24 and `m` which is 16x16. + * + * **Padding** + * + * 7px aligns better with other elements in the layout. + * We cannot use `euiTheme.size.s` because it's 8px. + */ const wrapperStyles = css` position: relative; padding-top: ${isCollapsed ? euiTheme.size.s : euiTheme.size.m}; + padding-bottom: ${isCollapsed ? euiTheme.size.s : euiTheme.size.m}; + + ${getHighContrastSeparator(euiThemeContext)} .euiText { font-weight: ${euiTheme.font.weight.bold}; @@ -64,7 +79,7 @@ export const Logo = ({ data-test-subj={logoTestSubj} isHighlighted={isHighlighted} isCurrent={isCurrent} - isLabelVisible={!isCollapsed && !hideLabel} + isLabelVisible={!isCollapsed} isTruncated={false} {...props} > diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx index 6f7abff91a137..6336aad9c982b 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/popover.tsx @@ -39,25 +39,17 @@ import { useHoverTimeout } from '../../hooks/use_hover_timeout'; import { useScroll } from '../../hooks/use_scroll'; export interface PopoverIds { - popoverNavigationInstructionsId: string | undefined; + popoverNavigationInstructionsId: string; } export type PopoverChildren = | ReactNode - | ((closePopover: () => void, ids: PopoverIds) => ReactNode); + | ((closePopover: () => void, ids?: PopoverIds) => ReactNode); export interface PopoverProps { - anchorPosition?: 'rightUp' | 'rightDown'; children?: PopoverChildren; container?: HTMLElement; - /** - * When `true`, the popover delegates keyboard navigation and focus - * management to its children (e.g. EuiSelectable). Only Escape is - * handled by the popover itself. - */ - customContent?: boolean; hasContent: boolean; - popoverWidth?: number | string; isSidePanelOpen: boolean; isAnyPopoverLocked?: boolean; setIsLocked?: (isLocked: boolean) => void; @@ -83,12 +75,9 @@ export interface PopoverProps { * - Escape to move focus back to the trigger. */ export const Popover = ({ - anchorPosition: anchorPositionProp, children, container, - customContent = false, hasContent, - popoverWidth, isSidePanelOpen, isAnyPopoverLocked = false, setIsLocked = () => {}, @@ -203,10 +192,6 @@ export const Popover = ({ return; } - if (customContent) { - return; - } - if (e.key === 'Tab') { e.preventDefault(); handleClose(); @@ -216,7 +201,7 @@ export const Popover = ({ handleRovingIndex(e); }, - [customContent, handleClose] + [handleClose] ); const handleBlur: FocusEventHandler = useCallback( @@ -276,15 +261,9 @@ export const Popover = ({ width: 100%; `; - const resolvedWidth = popoverWidth - ? typeof popoverWidth === 'number' - ? `${popoverWidth}px` - : popoverWidth - : `${SIDE_PANEL_WIDTH}px`; - const popoverContentStyles = css` --popover-max-height: 37.5rem; - width: ${resolvedWidth}; + width: ${SIDE_PANEL_WIDTH}px; max-height: var(--popover-max-height); ${scrollStyles}; `; @@ -314,7 +293,7 @@ export const Popover = ({ )} - {!customContent && ( - -

- {i18n.translate('core.ui.chrome.sideNavigation.popoverNavigationInstructions', { - defaultMessage: - 'You are in the {label} secondary menu dialog. Use Up and Down arrow keys to navigate the menu. Press Escape to exit to the menu trigger.', - values: { - label, - }, - })} -

-
- )} + +

+ {i18n.translate('core.ui.chrome.sideNavigation.popoverNavigationInstructions', { + defaultMessage: + 'You are in the {label} secondary menu dialog. Use Up and Down arrow keys to navigate the menu. Press Escape to exit to the menu trigger.', + values: { + label, + }, + })} +

+
{typeof children === 'function' - ? children(handleClose, { - popoverNavigationInstructionsId: customContent - ? undefined - : popoverNavigationInstructionsId, - }) + ? children(handleClose, { popoverNavigationInstructionsId }) : children}
diff --git a/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx b/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx index 6bc9fe5efb2f6..2e4f837ef5b8d 100644 --- a/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx +++ b/src/core/packages/chrome/navigation/src/components/side_nav/side_panel.tsx @@ -59,7 +59,7 @@ export interface SidePanelProps { } /** - * Side navigation panel that opens on mouse click if the top-level item contains a submenu. + * Side navigation panel that opens on mouse click if the primary menu item contains a submenu. * Shows only in expanded mode. */ export const SidePanel = ({ children, footer, openerNode }: SidePanelProps): JSX.Element => { diff --git a/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx b/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx deleted file mode 100644 index 93cc6bd881079..0000000000000 --- a/src/core/packages/chrome/navigation/src/components/tool_item/index.tsx +++ /dev/null @@ -1,35 +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 React, { forwardRef } from 'react'; -import type { KeyboardEvent, Ref } from 'react'; -import type { EuiButtonIconProps } from '@elastic/eui'; - -import type { ToolItem as ToolItemData } from '../../../types'; -import { IconButton } from '../icon_button'; -import { NAVIGATION_SELECTOR_PREFIX } from '../../constants'; - -export interface ToolItemProps - extends Omit, - Omit { - hasContent?: boolean; - isCollapsed?: boolean; - isHighlighted: boolean; - isNew: boolean; - onClick?: () => void; - onKeyDown?: (e: KeyboardEvent) => void; -} - -export const ToolItem = forwardRef(({ id, ...props }, ref) => ( - } - data-test-subj={`${NAVIGATION_SELECTOR_PREFIX}-toolItem-${id}`} - {...props} - /> -)); diff --git a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts index a94a690fa2d83..01620f070cfe5 100644 --- a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts +++ b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts @@ -32,7 +32,7 @@ interface NavigationState { * - `actualActiveItemId` - the actual active item ID. There can only be one `aria-current=page` link on the page. * - `visuallyActivePageId` - the visually active page ID. The link does not have to be `aria-current=page`, it can be a parent of an active page. * - `visuallyActiveSubpageId` - the visually active subpage ID. - * - `openerNode` - the top-level item whose submenu is shown in the side panel. + * - `openerNode` - the primary menu item whose submenu is shown in the side panel. * - `isCollapsed` - whether the side nav is collapsed. * - `isSidePanelOpen` - whether the side panel is open. */ diff --git a/src/core/packages/chrome/navigation/src/hooks/use_tool_shortcuts.ts b/src/core/packages/chrome/navigation/src/hooks/use_tool_shortcuts.ts deleted file mode 100644 index aaf07f4db879b..0000000000000 --- a/src/core/packages/chrome/navigation/src/hooks/use_tool_shortcuts.ts +++ /dev/null @@ -1,50 +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 { useCallback, useEffect } from 'react'; -import type { ToolItem } from '../../types'; - -/** - * Hook to handle keyboard shortcuts for tool items. - * @param tools - The tool items to handle shortcuts for. - */ - -interface UseToolShortcutsProps { - tools: ToolItem[]; -} - -const isMac = (() => { - if (typeof navigator === 'undefined') return false; - const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; - const platform = (nav.userAgentData?.platform ?? nav.userAgent ?? '').toLowerCase(); - return platform.includes('mac'); -})(); - -export const useToolShortcuts = ({ tools }: UseToolShortcutsProps) => { - const onKeyDown = useCallback( - (event: KeyboardEvent) => { - const toolWithShortcut = tools.find( - (tool) => - tool.shortcutKey && - event.key === tool.shortcutKey && - (isMac ? event.metaKey : event.ctrlKey) - ); - if (toolWithShortcut) { - event.preventDefault(); - toolWithShortcut.onClick?.(); - } - }, - [tools] - ); - - useEffect(() => { - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [onKeyDown]); -}; diff --git a/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts b/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts index ccceda286ab06..3fc92540427c1 100644 --- a/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts +++ b/src/core/packages/chrome/navigation/src/utils/get_has_submenu.ts @@ -7,15 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { MenuItem } from '../../types'; + /** * Utility function for checking whether the menu item has a submenu. * * @param item - the menu item to check. * @returns `true` if the menu item has a submenu, `false` otherwise. */ -export const getHasSubmenu = (item: { - sections?: ReadonlyArray; - renderPopover?: unknown; -}): boolean => { - return (!!item.sections && item.sections.length > 0) || !!item.renderPopover; +export const getHasSubmenu = (item: MenuItem): boolean => { + return !!item.sections && item.sections.length > 0; }; diff --git a/src/core/packages/chrome/navigation/types.ts b/src/core/packages/chrome/navigation/types.ts index 4b0fc7c1faa02..ad53ab969ba16 100644 --- a/src/core/packages/chrome/navigation/types.ts +++ b/src/core/packages/chrome/navigation/types.ts @@ -7,22 +7,19 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ReactNode } from 'react'; import type { IconType } from '@elastic/eui'; export type BadgeType = 'beta' | 'techPreview' | 'new'; /** * A navigation item within a secondary/nested menu. - * Secondary items appear when a top-level navigation item with sections is clicked or hovered. - * - * At least one of `href` or `onClick` must be provided. + * Secondary items appear when a primary menu item with sections is clicked or hovered. */ export interface SecondaryMenuItem { /** - * (optional) The URL for the menu item link. + * The URL for the menu item link. */ - href?: string; + href: string; /** * The unique identifier of the menu item. */ @@ -43,10 +40,6 @@ export interface SecondaryMenuItem { * (optional) Whether the link opens in a new tab. */ isExternal?: boolean; - /** - * (optional) Click handler for action items (e.g. opening a flyout or modal). - */ - onClick?: () => void; } /** @@ -70,7 +63,7 @@ export interface SecondaryMenuSection { /** * A primary navigation menu item that appears in the main navigation sidebar. - * Primary items must always be navigable links. + * Can optionally contain nested secondary menu sections. */ export interface MenuItem { /** @@ -104,43 +97,12 @@ export interface MenuItem { } /** - * A chrome tool control (search, help, profile, etc.) — not a navigation destination. - * Tool items can perform an action via `onClick` or expose submenu content via `sections`. - * - * At least one of `iconType` or `renderContent` must be provided. - * When both are present, `renderContent` takes precedence for the trigger visual. - * - * When `renderPopover` is present, it takes precedence over `sections` for the - * popover body content. They are not combined. - */ -export interface ToolItem { - id: string; - label: string; - iconType?: IconType; - renderContent?: (state: { isCollapsed: boolean }) => ReactNode; - renderPopover?: (closePopover: () => void) => ReactNode; - onClick?: () => void; - sections?: SecondaryMenuSection[]; - popoverWidth?: number | string; - badgeType?: BadgeType; - 'data-test-subj'?: string; - shortcutKey?: string; -} - -/** - * Optional groupings of tool controls for the sidenav header and footer toolbars. - */ -export interface ToolSlots { - headerTools?: ToolItem[]; - footerTools?: ToolItem[]; -} - -/** - * Navigable sidenav content: primary rail and footer links. + * The complete navigation structure containing primary and footer menu items. + * This is the main data structure passed to the Navigation component. */ export interface NavigationStructure { /** - * The items to be displayed in the navigation footer (navigable links). + * The items to be displayed in the navigation footer. */ footerItems: MenuItem[]; /** @@ -177,20 +139,10 @@ export interface SideNavLogo { * The label for the logo, typically the product name. */ label: string; - /** - * When `true`, the label is not rendered under the icon while the navigation is expanded. - * The logo link still uses `label` for `aria-label` and for the collapsed-mode tooltip. - */ - hideLabel?: boolean; /** * The logo type, e.g. `appObservability`, `appSecurity`, etc. */ iconType: string; - /** - * (optional) Color of the logo icon. `'default'` renders the icon in its brand colors; - * `'text'` renders it monochromatically using the current text color. - */ - iconColor?: 'default' | 'text'; /** * (optional) `data-test-subj` attribute for testing and tracking purposes. */ diff --git a/src/platform/plugins/shared/discover/moon.yml b/src/platform/plugins/shared/discover/moon.yml index e5e09a7f3ac1f..6b79293c583aa 100644 --- a/src/platform/plugins/shared/discover/moon.yml +++ b/src/platform/plugins/shared/discover/moon.yml @@ -143,6 +143,7 @@ dependsOn: - '@kbn/presentation-util-plugin' - '@kbn/core-saved-objects-common' - '@kbn/as-code-filters-constants' + - '@kbn/core-chrome-feature-flags' tags: - plugin - prod From 3ef4b1e0e2a8cf5f36dc4445f559f934da8e20ef Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 13 Apr 2026 10:29:15 +0200 Subject: [PATCH 25/77] [Chrome Next] Bootstrap GlobalHeader and composable header shells (#262582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Restructures the Chrome-Next header components into composable layout shells and pre-wired assemblies: - Renames `project_next/` to `chrome_next/` with `app_header/` and `global_header/` sub-folders - Creates `GlobalHeaderShell` and `AppHeaderShell` — pure layout components with ReactNode slot props that own the flex structure but not the content - `GlobalHeader` and `AppHeader` assemblies wire concrete leaf components (logo, back button, title, app menu, etc.) into the shells - `AppHeaderShell` is designed for the full end-state with slots for badges, metadata, callout, and tabs rows (currently unpopulated) - Bootstraps the `GlobalHeader` with Elastic logo home link and AI button - Moves AI button from app header to global header Screenshot 2026-04-10 at 17 52 35 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../chrome/browser-components/index.ts | 3 +- .../src/chrome_next/app_header/app_header.tsx | 26 ++++ .../app_header/app_header_shell.tsx | 143 ++++++++++++++++++ .../app_header}/app_menu.tsx | 17 +-- .../app_header/app_title.tsx} | 8 +- .../app_header}/back_button.tsx | 15 +- .../app_header}/global_actions.tsx | 17 +-- .../app_header}/hooks/index.ts | 3 +- .../app_header/hooks/use_app_header_menu.ts} | 5 +- .../app_header}/hooks/use_back_button.ts | 6 +- .../hooks/use_report_top_bar_height.ts | 1 - .../app_header}/hooks/use_title.ts | 7 +- .../app_header}/index.ts | 4 +- .../global_header}/ai_button_slot.tsx | 4 +- .../global_header/global_header.tsx | 19 +++ .../global_header/global_header_logo.tsx | 31 ++++ .../global_header/global_header_shell.tsx | 125 +++++++++++++++ .../src/chrome_next/global_header/index.ts | 12 ++ .../global_header}/use_ai_button.ts | 0 .../src/chrome_next/index.ts | 13 ++ .../src/project_next/header.tsx | 98 ------------ .../src/project_next/trailing_actions.tsx | 58 ------- .../chrome/browser/src/chrome_next/header.ts | 4 +- .../layouts/grid/grid_layout.tsx | 10 +- 24 files changed, 414 insertions(+), 215 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/app_menu.tsx (66%) rename src/core/packages/chrome/browser-components/src/{project_next/title.tsx => chrome_next/app_header/app_title.tsx} (82%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/back_button.tsx (66%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/global_actions.tsx (78%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/hooks/index.ts (84%) rename src/core/packages/chrome/browser-components/src/{project_next/hooks/use_project_next_app_menu.ts => chrome_next/app_header/hooks/use_app_header_menu.ts} (78%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/hooks/use_back_button.ts (89%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/hooks/use_report_top_bar_height.ts (99%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/hooks/use_title.ts (72%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/app_header}/index.ts (74%) rename src/core/packages/chrome/browser-components/src/{project_next => chrome_next/global_header}/ai_button_slot.tsx (87%) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts rename src/core/packages/chrome/browser-components/src/{project_next/hooks => chrome_next/global_header}/use_ai_button.ts (100%) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/index.ts delete mode 100644 src/core/packages/chrome/browser-components/src/project_next/header.tsx delete mode 100644 src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx diff --git a/src/core/packages/chrome/browser-components/index.ts b/src/core/packages/chrome/browser-components/index.ts index 0ed6dac9d116e..0b4d666ed0b73 100644 --- a/src/core/packages/chrome/browser-components/index.ts +++ b/src/core/packages/chrome/browser-components/index.ts @@ -12,7 +12,8 @@ export type { ChromeComponentsDeps } from './src/context'; export { ClassicHeader } from './src/classic'; export { ProjectHeader } from './src/project'; -export { ProjectNextHeader } from './src/project_next'; +export { AppHeader, AppHeaderShell, GlobalHeader, GlobalHeaderShell } from './src/chrome_next'; +export type { AppHeaderShellProps, GlobalHeaderShellProps } from './src/chrome_next'; export { GridLayoutProjectSideNav } from './src/project/sidenav/grid_layout_sidenav'; export { Sidebar } from './src/sidebar'; export { AppMenuBar } from './src/project/app_menu'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx new file mode 100644 index 0000000000000..cf4cf3be9218b --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx @@ -0,0 +1,26 @@ +/* + * 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 React from 'react'; +import { AppHeaderShell } from './app_header_shell'; +import { BackButton } from './back_button'; +import { AppTitle } from './app_title'; +import { GlobalActions } from './global_actions'; +import { AppMenu } from './app_menu'; + +export const AppHeader = React.memo(() => ( + } + title={} + titleActions={} + trailing={} + /> +)); + +AppHeader.displayName = 'AppHeader'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx new file mode 100644 index 0000000000000..9136bb15813b2 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx @@ -0,0 +1,143 @@ +/* + * 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 type { ReactNode } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { useReportTopBarHeight } from './hooks'; + +/** Minimum row height; aligns with project layout `applicationTopBarHeight`. */ +const APPLICATION_TOP_BAR_HEIGHT_PX = 48; + +export interface AppHeaderShellProps { + leading?: ReactNode; + title?: ReactNode; + badges?: ReactNode; + titleActions?: ReactNode; + trailing?: ReactNode; + metadata?: ReactNode; + callout?: ReactNode; + tabs?: ReactNode; +} + +const useHeaderStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { + const root = css` + display: flex; + flex-direction: column; + min-width: 0; + min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; + box-sizing: border-box; + padding: 0 ${euiTheme.size.s}; + background: ${euiTheme.colors.backgroundBasePlain}; + border-bottom: ${euiTheme.border.thin}; + margin-bottom: -${euiTheme.border.width.thin}; + `; + + const primaryRow = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.xs}; + min-width: 0; + min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; + `; + + const titleCluster = css` + display: flex; + align-items: center; + flex: 1; + min-width: 0; + `; + + const titleGroup = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.xs}; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + `; + + const titleClusterSpacer = css` + flex: 1 1 auto; + min-width: 0; + `; + + const metadataRow = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + padding-bottom: ${euiTheme.size.s}; + `; + + const calloutRow = css` + padding-bottom: ${euiTheme.size.s}; + `; + + const tabsRow = css` + display: flex; + align-items: stretch; + `; + + return { + root, + primaryRow, + titleCluster, + titleGroup, + titleClusterSpacer, + metadataRow, + calloutRow, + tabsRow, + }; + }, [euiTheme]); +}; + +export const AppHeaderShell = React.memo( + ({ leading, title, badges, titleActions, trailing, metadata, callout, tabs }) => { + const styles = useHeaderStyles(); + const heightRef = useReportTopBarHeight(); + + return ( +
+
+ {leading} +
+
+ {title} + {badges} + {titleActions} +
+
+
+ {trailing} +
+ {metadata && ( +
+ {metadata} +
+ )} + {callout && ( +
+ {callout} +
+ )} + {tabs && ( +
+ {tabs} +
+ )} +
+ ); + } +); + +AppHeaderShell.displayName = 'AppHeaderShell'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_menu.tsx similarity index 66% rename from src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_menu.tsx index ed2b371d66e5c..bcac05ad4c39b 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/app_menu.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_menu.tsx @@ -8,21 +8,18 @@ */ import React, { lazy, Suspense } from 'react'; -import { useHasLegacyActionMenu } from '../shared/chrome_hooks'; -import { HeaderActionMenu } from '../shared/header_action_menu'; -import { useProjectNextAppMenu } from './hooks'; +import { useHasLegacyActionMenu } from '../../shared/chrome_hooks'; +import { HeaderActionMenu } from '../../shared/header_action_menu'; +import { useAppHeaderMenu } from './hooks'; const AppMenuComponent = lazy(async () => { const { AppMenuComponent: Component } = await import('@kbn/core-chrome-app-menu-components'); return { default: Component }; }); -/** - * Renders the app menu for the Chrome-Next project header. - * Fallback chain: merged AppMenuConfig -> legacy HeaderActionMenu -> nothing. - */ -export const ProjectNextAppMenu = React.memo(() => { - const appMenuConfig = useProjectNextAppMenu(); +/** Fallback chain: AppMenuConfig -> legacy HeaderActionMenu -> nothing. */ +export const AppMenu = React.memo(() => { + const appMenuConfig = useAppHeaderMenu(); const hasLegacyActionMenu = useHasLegacyActionMenu(); if (appMenuConfig) { @@ -40,4 +37,4 @@ export const ProjectNextAppMenu = React.memo(() => { return null; }); -ProjectNextAppMenu.displayName = 'ProjectNextAppMenu'; +AppMenu.displayName = 'AppMenu'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/title.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_title.tsx similarity index 82% rename from src/core/packages/chrome/browser-components/src/project_next/title.tsx rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_title.tsx index 32bf62ce17f50..a29b06184f514 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/title.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_title.tsx @@ -32,11 +32,7 @@ const useTitleStyles = () => { }, [euiTheme]); }; -/** - * Title region for the Chrome-Next project header: renders nothing when - * `useTitle()` has no string; otherwise an `h1` with Emotion styles. - */ -export const ProjectNextTitle = React.memo(() => { +export const AppTitle = React.memo(() => { const title = useTitle(); const styles = useTitleStyles(); @@ -47,4 +43,4 @@ export const ProjectNextTitle = React.memo(() => { return

{title}

; }); -ProjectNextTitle.displayName = 'ProjectNextTitle'; +AppTitle.displayName = 'AppTitle'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx similarity index 66% rename from src/core/packages/chrome/browser-components/src/project_next/back_button.tsx rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx index bc79b3ca3e3d5..e35199356d17d 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/back_button.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx @@ -12,12 +12,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { useBackButton } from './hooks'; -/** - * Back control for Chrome-Next project header: uses explicit `chrome.next.header` `back` when - * set; otherwise the last non-last project breadcrumb with an `href` (≥2 crumbs; scanning - * right to left). Renders nothing when neither applies. - */ -export const ProjectNextBackButton = React.memo(() => { +export const BackButton = React.memo(() => { const back = useBackButton(); const ariaLabel = useMemo(() => { @@ -25,12 +20,12 @@ export const ProjectNextBackButton = React.memo(() => { return ''; } if (back.backDestinationLabel) { - return i18n.translate('core.ui.chrome.projectNextHeader.backButtonAriaLabelWithDestination', { + return i18n.translate('core.ui.chrome.appHeader.backButtonAriaLabelWithDestination', { defaultMessage: 'Back to {destination}', values: { destination: back.backDestinationLabel }, }); } - return i18n.translate('core.ui.chrome.projectNextHeader.backButtonAriaLabel', { + return i18n.translate('core.ui.chrome.appHeader.backButtonAriaLabel', { defaultMessage: 'Back', }); }, [back]); @@ -46,10 +41,10 @@ export const ProjectNextBackButton = React.memo(() => { display="empty" size="s" aria-label={ariaLabel} - data-test-subj="chromeProjectNextHeaderBack" + data-test-subj="chromeNextAppHeaderBack" href={back.backHref} /> ); }); -ProjectNextBackButton.displayName = 'ProjectNextBackButton'; +BackButton.displayName = 'BackButton'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/global_actions.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/global_actions.tsx similarity index 78% rename from src/core/packages/chrome/browser-components/src/project_next/global_actions.tsx rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/global_actions.tsx index d298186962694..2e3d0b0c8c389 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/global_actions.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/global_actions.tsx @@ -11,9 +11,9 @@ import { EuiButtonIcon, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; -import { useNextHeader } from '../shared/chrome_hooks'; +import { useNextHeader } from '../../shared/chrome_hooks'; -const SHARE_ARIA_LABEL = i18n.translate('core.ui.chrome.projectNextHeader.globalShareAriaLabel', { +const SHARE_ARIA_LABEL = i18n.translate('core.ui.chrome.appHeader.globalShareAriaLabel', { defaultMessage: 'Share', }); @@ -39,12 +39,11 @@ const useGlobalActionsStyles = () => { }; /** - * Fixed-order global object actions (editTitle, share, favorite) next to the Chrome-Next title. + * Fixed-order global object actions (editTitle, share, favorite) next to the title. * Only renders actions the page opts into via `chrome.next.header.set({ globalActions })`. - * * Favorite is a `ReactNode` slot so plugins own full behavior (clients, context, React Query). */ -export const ProjectNextGlobalActions = React.memo(() => { +export const GlobalActions = React.memo(() => { const config = useNextHeader(); const styles = useGlobalActionsStyles(); const globalActions = config?.globalActions; @@ -60,7 +59,7 @@ export const ProjectNextGlobalActions = React.memo(() => { } return ( -
+
{/* TODO: editTitle — Chrome-controlled inline title editor; wire onSave from config */} {share ? ( { display="empty" size="s" aria-label={SHARE_ARIA_LABEL} - data-test-subj="chromeProjectNextHeaderGlobalShare" + data-test-subj="chromeNextAppHeaderGlobalShare" onClick={share.onClick} /> ) : null} {favorite ? ( -
+
{favorite}
) : null} @@ -82,4 +81,4 @@ export const ProjectNextGlobalActions = React.memo(() => { ); }); -ProjectNextGlobalActions.displayName = 'ProjectNextGlobalActions'; +GlobalActions.displayName = 'GlobalActions'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/index.ts similarity index 84% rename from src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/index.ts index 0b3ba150d2b3c..0b070a122b120 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/index.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/index.ts @@ -7,8 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { useAiButtons } from './use_ai_button'; -export { useProjectNextAppMenu } from './use_project_next_app_menu'; +export { useAppHeaderMenu } from './use_app_header_menu'; export { useBackButton } from './use_back_button'; export { useReportTopBarHeight } from './use_report_top_bar_height'; export { useTitle } from './use_title'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_app_header_menu.ts similarity index 78% rename from src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_app_header_menu.ts index 33c08b3d5c4dc..d742f0ffaa742 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_project_next_app_menu.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_app_header_menu.ts @@ -8,13 +8,12 @@ */ import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; -import { useAppMenu, useNextHeader } from '../../shared/chrome_hooks'; +import { useAppMenu, useNextHeader } from '../../../shared/chrome_hooks'; /** - * Returns the app menu config for the Chrome-Next project header. * Fallback: `config.appMenu` from `chrome.next.header.set()` -> global `chrome.getAppMenu$()`. */ -export function useProjectNextAppMenu(): AppMenuConfig | undefined { +export function useAppHeaderMenu(): AppMenuConfig | undefined { const config = useNextHeader(); const globalAppMenu = useAppMenu(); return config?.appMenu ?? globalAppMenu; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_back_button.ts similarity index 89% rename from src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.ts rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_back_button.ts index 20719383c6028..d6cf302e97c52 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_back_button.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_back_button.ts @@ -8,8 +8,8 @@ */ import { useMemo } from 'react'; -import { useNextHeader, useProjectBreadcrumbs } from '../../shared/chrome_hooks'; -import { getBreadcrumbPlainText } from '../../shared/breadcrumb_utils'; +import { useNextHeader, useProjectBreadcrumbs } from '../../../shared/chrome_hooks'; +import { getBreadcrumbPlainText } from '../../../shared/breadcrumb_utils'; export interface BackNavigation { backHref: string; @@ -18,7 +18,7 @@ export interface BackNavigation { } /** - * Resolution: explicit `chrome.next.header` `back.href` (and optional `back.label`) → else the + * Resolution: explicit `chrome.next.header` `back.href` (and optional `back.label`) -> else the * last non-last project breadcrumb with a truthy `href` (scanning right to left). Returns * `undefined` if neither applies. */ diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_report_top_bar_height.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_report_top_bar_height.ts similarity index 99% rename from src/core/packages/chrome/browser-components/src/project_next/hooks/use_report_top_bar_height.ts rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_report_top_bar_height.ts index 046223bcef46a..55c0486deec1a 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_report_top_bar_height.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_report_top_bar_height.ts @@ -13,7 +13,6 @@ import { useLayoutUpdate } from '@kbn/core-chrome-layout-components'; /** * Measures the height of the application top bar via ResizeObserver and * reports it to the layout system through `useLayoutUpdate`. - * * Returns a ref callback to attach to the top bar root element. * Reports `0` when the element is removed or on unmount. */ diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_title.ts similarity index 72% rename from src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_title.ts index c2f084c5decc1..047ef3aa8b05f 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_title.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_title.ts @@ -7,12 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useNextHeader, useProjectBreadcrumbs } from '../../shared/chrome_hooks'; -import { getBreadcrumbPlainText } from '../../shared/breadcrumb_utils'; +import { useNextHeader, useProjectBreadcrumbs } from '../../../shared/chrome_hooks'; +import { getBreadcrumbPlainText } from '../../../shared/breadcrumb_utils'; /** - * Returns the display title for the Chrome-Next project header, or `undefined` when none. - * Resolution: explicit `config.title` -> project breadcrumb text (last crumb) -> `undefined`. + * Resolution: explicit `config.title` -> last project breadcrumb text -> `undefined`. */ export function useTitle(): string | undefined { const config = useNextHeader(); diff --git a/src/core/packages/chrome/browser-components/src/project_next/index.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/index.ts similarity index 74% rename from src/core/packages/chrome/browser-components/src/project_next/index.ts rename to src/core/packages/chrome/browser-components/src/chrome_next/app_header/index.ts index 1743a03c9c3b0..d5fd4180431f8 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/index.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/index.ts @@ -7,4 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { ProjectNextHeader } from './header'; +export { AppHeader } from './app_header'; +export { AppHeaderShell } from './app_header_shell'; +export type { AppHeaderShellProps } from './app_header_shell'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/ai_button_slot.tsx similarity index 87% rename from src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx rename to src/core/packages/chrome/browser-components/src/chrome_next/global_header/ai_button_slot.tsx index 97765863b4771..d4cc22d0fc20e 100644 --- a/src/core/packages/chrome/browser-components/src/project_next/ai_button_slot.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/ai_button_slot.tsx @@ -8,8 +8,8 @@ */ import React from 'react'; -import { HeaderExtension } from '../shared/header_extension'; -import { useAiButtons } from './hooks'; +import { HeaderExtension } from '../../shared/header_extension'; +import { useAiButtons } from './use_ai_button'; export const AiButtonSlot = React.memo(() => { const buttons = useAiButtons(); diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx new file mode 100644 index 0000000000000..3e09feda7bb6b --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx @@ -0,0 +1,19 @@ +/* + * 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 React from 'react'; +import { GlobalHeaderShell } from './global_header_shell'; +import { GlobalHeaderLogo } from './global_header_logo'; +import { AiButtonSlot } from './ai_button_slot'; + +export const GlobalHeader = React.memo(() => ( + } actions={} /> +)); + +GlobalHeader.displayName = 'GlobalHeader'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx new file mode 100644 index 0000000000000..026daf85040dd --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx @@ -0,0 +1,31 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useProjectHome, useBasePath, useCustomBranding } from '../../shared/chrome_hooks'; +import { LoadingIndicator } from '../../shared/loading_indicator'; + +const LOGO_ARIA_LABEL = i18n.translate('core.ui.chrome.globalHeader.logoAriaLabel', { + defaultMessage: 'Elastic home', +}); + +export const GlobalHeaderLogo = React.memo(() => { + const basePath = useBasePath(); + const homeHref = basePath.prepend(useProjectHome()); + const { logo: customLogo } = useCustomBranding(); + + return ( + + + + ); +}); + +GlobalHeaderLogo.displayName = 'GlobalHeaderLogo'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx new file mode 100644 index 0000000000000..b77d454901b37 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx @@ -0,0 +1,125 @@ +/* + * 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 type { ReactNode } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { COLLAPSED_WIDTH, EXPANDED_WIDTH } from '@kbn/core-chrome-navigation'; +import { useSideNavCollapsed } from '@kbn/core-chrome-browser-hooks'; +import React, { useMemo } from 'react'; + +const GLOBAL_HEADER_HEIGHT_PX = 48; + +const logoSlot = css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 'var(--logo-width)', + height: GLOBAL_HEADER_HEIGHT_PX, + flexShrink: 0, +}); + +export interface GlobalHeaderShellProps { + logo?: ReactNode; + switcher?: ReactNode; + search?: ReactNode; + actions?: ReactNode; +} + +const useGlobalHeaderStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { + const root = css` + display: flex; + align-items: center; + height: ${GLOBAL_HEADER_HEIGHT_PX}px; + box-sizing: border-box; + padding: 0 ${euiTheme.size.s} 0 0; + background: ${euiTheme.colors.backgroundTransparent}; + `; + + const leftGroup = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + flex-shrink: 0; + `; + + const switcherSlot = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.xs}; + `; + + const spacer = css` + flex: 1 1 auto; + min-width: 0; + `; + + const rightGroup = css` + display: flex; + align-items: center; + flex-shrink: 0; + gap: ${euiTheme.size.s}; + `; + + const searchSlot = css` + display: flex; + align-items: center; + flex-shrink: 0; + `; + + const actionsSlot = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.s}; + `; + + return { root, leftGroup, switcherSlot, spacer, rightGroup, searchSlot, actionsSlot }; + }, [euiTheme]); +}; + +export const GlobalHeaderShell = React.memo( + ({ logo, switcher, search, actions }) => { + const { isCollapsed } = useSideNavCollapsed(); + const styles = useGlobalHeaderStyles(); + const logoWidth = isCollapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; + + return ( +
+
+
+ {logo} +
+ {switcher && ( +
+ {switcher} +
+ )} +
+
+
+ {search && ( +
+ {search} +
+ )} + {actions && ( +
+ {actions} +
+ )} +
+
+ ); + } +); + +GlobalHeaderShell.displayName = 'GlobalHeaderShell'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts new file mode 100644 index 0000000000000..fcdd0fa6bd0f4 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts @@ -0,0 +1,12 @@ +/* + * 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". + */ + +export { GlobalHeader } from './global_header'; +export { GlobalHeaderShell } from './global_header_shell'; +export type { GlobalHeaderShellProps } from './global_header_shell'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/use_ai_button.ts similarity index 100% rename from src/core/packages/chrome/browser-components/src/project_next/hooks/use_ai_button.ts rename to src/core/packages/chrome/browser-components/src/chrome_next/global_header/use_ai_button.ts diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/index.ts b/src/core/packages/chrome/browser-components/src/chrome_next/index.ts new file mode 100644 index 0000000000000..222d68d187f29 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/index.ts @@ -0,0 +1,13 @@ +/* + * 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". + */ + +export { AppHeader, AppHeaderShell } from './app_header'; +export type { AppHeaderShellProps } from './app_header'; +export { GlobalHeader, GlobalHeaderShell } from './global_header'; +export type { GlobalHeaderShellProps } from './global_header'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/header.tsx b/src/core/packages/chrome/browser-components/src/project_next/header.tsx deleted file mode 100644 index f2f20babb0b6b..0000000000000 --- a/src/core/packages/chrome/browser-components/src/project_next/header.tsx +++ /dev/null @@ -1,98 +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 { useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import React, { useMemo } from 'react'; -import { ProjectNextBackButton } from './back_button'; -import { ProjectNextGlobalActions } from './global_actions'; -import { useReportTopBarHeight } from './hooks'; -import { ProjectNextTitle } from './title'; -import { ProjectNextTrailingActions } from './trailing_actions'; - -/** Application top bar height; aligns with project layout `applicationTopBarHeight`. */ -const APPLICATION_TOP_BAR_HEIGHT_PX = 48; - -const useHeaderStyles = () => { - const { euiTheme } = useEuiTheme(); - - return useMemo(() => { - const root = css` - display: flex; - flex-direction: column; - min-width: 0; - height: 100%; - min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; - box-sizing: border-box; - padding: 0 ${euiTheme.size.s}; - background: ${euiTheme.colors.backgroundBasePlain}; - border-bottom: ${euiTheme.border.thin}; - margin-bottom: -${euiTheme.border.width.thin}; - `; - - const primaryRow = css` - display: flex; - align-items: center; - gap: ${euiTheme.size.xs}; - min-width: 0; - flex: 1; - min-height: 0; - `; - - const titleCluster = css` - display: flex; - align-items: center; - flex: 1; - min-width: 0; - `; - - /** - * Keeps the title and global actions grouped on the left; the title truncates with ellipsis - * instead of growing and pushing icons toward the app menu (trailing) region. - */ - const titleGroup = css` - display: flex; - align-items: center; - gap: ${euiTheme.size.xs}; - flex: 0 1 auto; - min-width: 0; - max-width: 100%; - `; - - const titleClusterSpacer = css` - flex: 1 1 auto; - min-width: 0; - `; - - return { root, primaryRow, titleCluster, titleGroup, titleClusterSpacer }; - }, [euiTheme]); -}; - -export const ProjectNextHeader = React.memo(() => { - const styles = useHeaderStyles(); - const heightRef = useReportTopBarHeight(); - - return ( -
-
- -
-
- - -
-
-
- -
-
- ); -}); - -ProjectNextHeader.displayName = 'ProjectNextHeader'; diff --git a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx b/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx deleted file mode 100644 index cc5ec747af9f8..0000000000000 --- a/src/core/packages/chrome/browser-components/src/project_next/trailing_actions.tsx +++ /dev/null @@ -1,58 +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 { useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import React, { useMemo } from 'react'; -import { useHasLegacyActionMenu } from '../shared/chrome_hooks'; -import { AiButtonSlot } from './ai_button_slot'; -import { ProjectNextAppMenu } from './app_menu'; -import { useAiButtons, useProjectNextAppMenu } from './hooks'; - -const useTrailingStyles = () => { - const { euiTheme } = useEuiTheme(); - - return useMemo(() => { - const root = css` - display: flex; - align-items: center; - gap: ${euiTheme.size.s}; - flex-shrink: 0; - margin-left: auto; - `; - - return { root }; - }, [euiTheme]); -}; - -/** - * Trailing region of the Chrome-Next project header (app menu, AI button, future global actions). - * Renders nothing when no trailing content is available. - */ -export const ProjectNextTrailingActions = React.memo(() => { - const appMenuConfig = useProjectNextAppMenu(); - const hasLegacyActionMenu = useHasLegacyActionMenu(); - const aiButtons = useAiButtons(); - const styles = useTrailingStyles(); - - const hasTrailingContent = !!appMenuConfig || hasLegacyActionMenu || aiButtons.length > 0; - - if (!hasTrailingContent) { - return null; - } - - return ( -
- - -
- ); -}); - -ProjectNextTrailingActions.displayName = 'ProjectNextTrailingActions'; diff --git a/src/core/packages/chrome/browser/src/chrome_next/header.ts b/src/core/packages/chrome/browser/src/chrome_next/header.ts index 795a68924bb88..59b75eb0beaee 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/header.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/header.ts @@ -24,13 +24,13 @@ export interface ChromeNextHeaderConfig { /** * Badges inline next to the title. Chrome shows 1–2 as-is; for 3+, first badge plus "+N" popover - * for the rest. Max 200px per badge; `filled` is not exposed. TODO: render in `ProjectNextHeader`. + * for the rest. Max 200px per badge; `filled` is not exposed. TODO: render in `AppHeader`. */ badges?: ChromeNextHeaderBadge[]; /** * Second row below the title (max 3 items, all visible). Text (`EuiText`) or button (`EuiButtonEmpty`). - * TODO: render in `ProjectNextHeader`. + * TODO: render in `AppHeader`. */ metadata?: ChromeNextHeaderMetadataSlotItem[]; diff --git a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx index 6e2d44dc74e99..e9035e6b0a99a 100644 --- a/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx +++ b/src/core/packages/chrome/layout/core-chrome-layout/layouts/grid/grid_layout.tsx @@ -15,7 +15,8 @@ import { ChromeComponentsProvider, ClassicHeader, ProjectHeader, - ProjectNextHeader, + AppHeader, + GlobalHeader, GridLayoutProjectSideNav, HeaderTopBanner, ChromelessHeader, @@ -66,14 +67,12 @@ const layoutConfigs: { }, projectNext: { chromeStyle: 'project', - headerHeight: 0, + headerHeight: 48, bannerHeight: 32, - /** Height is reported dynamically by ProjectNextHeader via useReportLayoutHeight */ applicationTopBarHeight: 0, applicationMarginRight: 8, applicationMarginBottom: 8, - applicationMarginTop: 8, sidebarWidth: 0, footerHeight: 0, navigationWidth: 0, @@ -124,8 +123,9 @@ const useChromeSlots = (nextChrome: boolean): ChromeSlots => { if (nextChrome) { return { ...base, + header: , navigation: , - applicationTopBar: , + applicationTopBar: , }; } From 6ef32ee36ef373b52d8eb6b8a1f3943dcb4f302a Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Mon, 13 Apr 2026 11:44:51 +0200 Subject: [PATCH 26/77] [Chrome Next] Add badges (#262715) ## Summary This PR adds badges API for chrome next. It intentionally does NOT migrate badges that use `renderCustomBadge` as they use functionality that does not exist for chrome next badges (e.g. open popovers, have icons) and changing that requires design decisions. Closes: https://github.com/elastic/kibana/issues/259987 --- .../src/chrome_next/app_header/app_badge.tsx | 67 ++++++++++++ .../src/chrome_next/app_header/app_badges.tsx | 103 ++++++++++++++++++ .../src/chrome_next/app_header/app_header.tsx | 2 + .../app_header/hooks/use_app_badges.ts | 56 ++++++++++ .../chrome/browser-internal-types/index.ts | 6 + .../browser-internal/src/chrome_api.tsx | 1 + .../browser-mocks/src/chrome_service.mock.ts | 1 + .../chrome/browser/src/chrome_next/header.ts | 5 +- src/core/packages/chrome/browser/src/types.ts | 3 + 9 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badge.tsx create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badges.tsx create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_app_badges.ts diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badge.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badge.tsx new file mode 100644 index 0000000000000..17c2eaba83107 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badge.tsx @@ -0,0 +1,67 @@ +/* + * 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 React, { useMemo } from 'react'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import type { ChromeNextHeaderBadge } from '@kbn/core-chrome-browser/src'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; + +const useBadgeStyle = () => { + return useMemo(() => { + const badge = css` + max-width: 200px; + `; + + return { badge }; + }, []); +}; + +export const AppBadge = ({ badge }: { badge: ChromeNextHeaderBadge }) => { + const { badge: badgeStyle } = useBadgeStyle(); + + // @ts-expect-error supported for backward compatibility. TODO: Remove it + if (badge?.renderCustomBadge) { + // @ts-expect-error supported for backward compatibility. TODO: Remove it + return badge.renderCustomBadge({ badgeText: badge.label }); + } + + const badgeOnClickAriaLabel = + badge?.onClickAriaLabel ?? + i18n.translate('core.ui.chrome.appHeader.badge.ariaLabel', { + defaultMessage: 'Click {label} badge', + values: { label: badge.label }, + }); + + const handleBadgeClick = () => { + if (badge?.onClick) { + badge.onClick(); + } + }; + + const badgeComponent = ( + + {badge.label} + + ); + + if (badge?.tooltip) { + return {badgeComponent}; + } + + return badgeComponent; +}; + +AppBadge.displayName = 'AppBadge'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badges.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badges.tsx new file mode 100644 index 0000000000000..87a30a0a36cc4 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_badges.tsx @@ -0,0 +1,103 @@ +/* + * 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 React, { memo, useMemo, useState } from 'react'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiPopover, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { AppBadge } from './app_badge'; +import { useAppBadges } from './hooks/use_app_badges'; + +const MAX_VISIBLE_BADGES = 2; + +const useBadgesStyle = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { + const badgesContainer = css` + margin-left: ${euiTheme.size.s}; + `; + + return { badgesContainer }; + }, [euiTheme]); +}; + +export const AppBadges = memo(() => { + const badges = useAppBadges(); + const { badgesContainer } = useBadgesStyle(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + if (!badges || badges.length === 0) { + return null; + } + + const visibleBadges = badges.slice(0, MAX_VISIBLE_BADGES); + const overflowBadges = badges.slice(MAX_VISIBLE_BADGES); + + const handleClosePopover = () => { + setIsPopoverOpen(false); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((open) => !open); + }; + + return ( + + {visibleBadges.map((badge) => ( + + + + ))} + {overflowBadges.length > 0 && ( + + + +{overflowBadges.length} + + } + isOpen={isPopoverOpen} + closePopover={handleClosePopover} + panelPaddingSize="s" + > + + {overflowBadges.map((badge) => ( + + + + ))} + + + + )} + + ); +}); + +AppBadges.displayName = 'AppBadges'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx index cf4cf3be9218b..dd21326aa47a5 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { AppHeaderShell } from './app_header_shell'; +import { AppBadges } from './app_badges'; import { BackButton } from './back_button'; import { AppTitle } from './app_title'; import { GlobalActions } from './global_actions'; @@ -18,6 +19,7 @@ export const AppHeader = React.memo(() => ( } title={} + badges={} titleActions={} trailing={} /> diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_app_badges.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_app_badges.ts new file mode 100644 index 0000000000000..9eb4aadcf6792 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_app_badges.ts @@ -0,0 +1,56 @@ +/* + * 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 { useMemo } from 'react'; +import type { ChromeNextHeaderBadge } from '@kbn/core-chrome-browser/src'; +import type { ChromeBadge, ChromeBreadcrumbsBadge } from '@kbn/core-chrome-browser'; +import { useObservable } from '@kbn/use-observable'; +import { useChromeService } from '@kbn/core-chrome-browser-context'; +import { useNextHeader } from '../../../shared/chrome_hooks'; + +const breadcrumbsBadgeToHeaderBadge = (badge: ChromeBreadcrumbsBadge): ChromeNextHeaderBadge => ({ + label: badge.badgeText, + tooltip: badge.toolTipProps?.content as string | undefined, + // @ts-expect-error supported for backward compatibility. TODO: Remove it + renderCustomBadge: badge.renderCustomBadge, +}); + +const legacyBadgeToHeaderBadge = (badge: ChromeBadge): ChromeNextHeaderBadge => ({ + label: badge.text, + tooltip: badge.tooltip, +}); + +/** + * Fallback: `config.badges` from `chrome.next.header.set()` -> + * legacy `chrome.setBadge()` + `chrome.setBreadcrumbsBadges()` combined. + */ +export function useAppBadges(): ChromeNextHeaderBadge[] | undefined { + const config = useNextHeader(); + const chrome = useChromeService(); + const breadcrumbsBadges$ = useMemo(() => chrome.getBreadcrumbsBadges$(), [chrome]); + const breadcrumbsBadges = useObservable(breadcrumbsBadges$, []); + const legacyBadge$ = useMemo(() => chrome.getBadge$(), [chrome]); + const legacyBadge = useObservable(legacyBadge$, undefined); + + if (config?.badges) { + return config.badges; + } + + const fallback: ChromeNextHeaderBadge[] = []; + + if (legacyBadge) { + fallback.push(legacyBadgeToHeaderBadge(legacyBadge)); + } + + if (breadcrumbsBadges.length > 0) { + fallback.push(...breadcrumbsBadges.map(breadcrumbsBadgeToHeaderBadge)); + } + + return fallback.length > 0 ? fallback : undefined; +} diff --git a/src/core/packages/chrome/browser-internal-types/index.ts b/src/core/packages/chrome/browser-internal-types/index.ts index d76ccac6389cb..0f1296776b5f4 100644 --- a/src/core/packages/chrome/browser-internal-types/index.ts +++ b/src/core/packages/chrome/browser-internal-types/index.ts @@ -15,6 +15,7 @@ import type { ChromeBadge, ChromeBreadcrumb, ChromeBreadcrumbsAppendExtension, + ChromeBreadcrumbsBadge, ChromeNext, ChromeNextAiButton, ChromeNextHeaderConfig, @@ -63,6 +64,11 @@ export interface InternalChromeStart extends ChromeStart { */ getBreadcrumbsAppendExtensionsWithBadges$(): Observable; + /** + * Get an observable of the current breadcrumbs badges set via setBreadcrumbsBadges(). + */ + getBreadcrumbsBadges$(): Observable; + /** Set global footer. Used by the developer toolbar. */ setGlobalFooter(node: ReactNode): void; diff --git a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx index 8c5740c4b15ae..18bf68f647007 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx @@ -113,6 +113,7 @@ export function createChromeApi({ state, services, sidebar }: ChromeApiDeps): In }, getBreadcrumbsAppendExtensions$: () => state.breadcrumbs.appendExtensions.$, getBreadcrumbsAppendExtensionsWithBadges$: () => state.breadcrumbs.appendExtensionsWithBadges$, + getBreadcrumbsBadges$: () => state.breadcrumbs.badges.$, setBreadcrumbsAppendExtension: (extension) => { state.breadcrumbs.appendExtensions.addSorted( extension, diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index 2cde4142f2110..249f13f19f0b7 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -149,6 +149,7 @@ const createStartContractMock = () => { getAppMenu$: jest.fn().mockReturnValue(new BehaviorSubject(undefined)), setAppMenu: jest.fn(), setBreadcrumbsBadges: jest.fn(), + getBreadcrumbsBadges$: jest.fn().mockReturnValue(new BehaviorSubject([])), }); return startContract; diff --git a/src/core/packages/chrome/browser/src/chrome_next/header.ts b/src/core/packages/chrome/browser/src/chrome_next/header.ts index 59b75eb0beaee..3171a72164842 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/header.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/header.ts @@ -24,7 +24,7 @@ export interface ChromeNextHeaderConfig { /** * Badges inline next to the title. Chrome shows 1–2 as-is; for 3+, first badge plus "+N" popover - * for the rest. Max 200px per badge; `filled` is not exposed. TODO: render in `AppHeader`. + * for the rest. Max 200px per badge; `filled` is not exposed. */ badges?: ChromeNextHeaderBadge[]; @@ -78,6 +78,9 @@ export interface ChromeNextHeaderBadge { /** EUI badge color. `filled` is intentionally excluded. */ color?: 'hollow' | 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'accent'; tooltip?: string; + onClick?: () => void; + onClickAriaLabel?: string; + 'data-test-subj'?: string; } /** diff --git a/src/core/packages/chrome/browser/src/types.ts b/src/core/packages/chrome/browser/src/types.ts index 92582f0dbe106..8fc83f559c945 100644 --- a/src/core/packages/chrome/browser/src/types.ts +++ b/src/core/packages/chrome/browser/src/types.ts @@ -21,6 +21,9 @@ export interface ChromeBadge { export type ChromeBreadcrumbsBadge = EuiBadgeProps & { badgeText: string; toolTipProps?: Partial; + /** + * @deprecated + */ renderCustomBadge?: (props: { badgeText: string }) => ReactElement; }; From 104a7b38ceb42d66417c8df6e8a68af896741c0b Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 13 Apr 2026 17:04:39 +0200 Subject: [PATCH 27/77] [FeatureBranch/DoNotReview][Chrome Next] header: separators, help button, user profile slot (#262798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Catches up the Chrome-Next global header with the design prototype: - **Vertical separators** between logo, spacer, and action groups - **Help button** in the global header, reusing `HeaderHelpMenu` popover logic via a `renderButton` callback - **User profile slot** — simplified `chrome.next.userMenu` API to accept a `ReactNode`; Security plugin provides `SecurityNavControl` with a `renderButton` prop so Chrome-Next controls the avatar button styling - **`HeaderActionButton`** — shared 32px action button component (bordered/plain variants) used by both help and user menu buttons, consolidating header button styling in one place Screenshot 2026-04-13 at 16 48 05 --- .../chrome/browser-components/index.ts | 14 +- .../global_header/global_header.tsx | 9 +- .../global_header/global_header_shell.tsx | 46 ++++++- .../global_header/header_action_button.tsx | 94 +++++++++++++ .../chrome_next/global_header/help_button.tsx | 40 ++++++ .../src/chrome_next/global_header/index.ts | 2 + .../src/chrome_next/index.ts | 4 +- .../src/shared/chrome_hooks.ts | 7 + .../src/shared/header_help_menu.tsx | 10 +- .../chrome/browser-internal-types/index.ts | 3 +- .../src/state/chrome_state.ts | 5 +- .../browser-mocks/src/chrome_service.mock.ts | 8 +- src/core/packages/chrome/browser/index.ts | 2 - .../browser/src/chrome_next/chrome_next.ts | 9 +- .../chrome/browser/src/chrome_next/index.ts | 1 - .../browser/src/chrome_next/user_menu.ts | 15 +- src/core/packages/chrome/browser/src/index.ts | 2 - .../platform/plugins/shared/security/moon.yml | 1 + .../nav_control_component.test.tsx | 33 +++-- .../nav_control/nav_control_component.tsx | 53 +++++--- .../nav_control/nav_control_service.tsx | 128 +++++------------- .../plugins/shared/security/tsconfig.json | 3 +- 22 files changed, 328 insertions(+), 161 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/global_header/help_button.tsx diff --git a/src/core/packages/chrome/browser-components/index.ts b/src/core/packages/chrome/browser-components/index.ts index 0b4d666ed0b73..657617840ec7e 100644 --- a/src/core/packages/chrome/browser-components/index.ts +++ b/src/core/packages/chrome/browser-components/index.ts @@ -12,8 +12,18 @@ export type { ChromeComponentsDeps } from './src/context'; export { ClassicHeader } from './src/classic'; export { ProjectHeader } from './src/project'; -export { AppHeader, AppHeaderShell, GlobalHeader, GlobalHeaderShell } from './src/chrome_next'; -export type { AppHeaderShellProps, GlobalHeaderShellProps } from './src/chrome_next'; +export { + AppHeader, + AppHeaderShell, + GlobalHeader, + GlobalHeaderShell, + HeaderActionButton, +} from './src/chrome_next'; +export type { + AppHeaderShellProps, + GlobalHeaderShellProps, + HeaderActionButtonProps, +} from './src/chrome_next'; export { GridLayoutProjectSideNav } from './src/project/sidenav/grid_layout_sidenav'; export { Sidebar } from './src/sidebar'; export { AppMenuBar } from './src/project/app_menu'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx index 3e09feda7bb6b..5120afd082471 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx @@ -11,9 +11,16 @@ import React from 'react'; import { GlobalHeaderShell } from './global_header_shell'; import { GlobalHeaderLogo } from './global_header_logo'; import { AiButtonSlot } from './ai_button_slot'; +import { HelpButton } from './help_button'; +import { useUserMenu } from '../../shared/chrome_hooks'; export const GlobalHeader = React.memo(() => ( - } actions={} /> + } + help={} + actions={} + userMenu={useUserMenu()} + /> )); GlobalHeader.displayName = 'GlobalHeader'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx index b77d454901b37..55063ec1e0ee0 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx @@ -29,7 +29,9 @@ export interface GlobalHeaderShellProps { logo?: ReactNode; switcher?: ReactNode; search?: ReactNode; + help?: ReactNode; actions?: ReactNode; + userMenu?: ReactNode; } const useGlobalHeaderStyles = () => { @@ -82,12 +84,40 @@ const useGlobalHeaderStyles = () => { gap: ${euiTheme.size.s}; `; - return { root, leftGroup, switcherSlot, spacer, rightGroup, searchSlot, actionsSlot }; + const helpSlot = css` + display: flex; + align-items: center; + `; + + const userMenuSlot = css` + display: flex; + align-items: center; + `; + + const separator = css` + width: 1px; + height: 20px; + flex-shrink: 0; + background: ${euiTheme.colors.borderBaseSubdued}; + `; + + return { + root, + leftGroup, + switcherSlot, + spacer, + rightGroup, + searchSlot, + actionsSlot, + helpSlot, + userMenuSlot, + separator, + }; }, [euiTheme]); }; export const GlobalHeaderShell = React.memo( - ({ logo, switcher, search, actions }) => { + ({ logo, switcher, search, help, actions, userMenu }) => { const { isCollapsed } = useSideNavCollapsed(); const styles = useGlobalHeaderStyles(); const logoWidth = isCollapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; @@ -104,18 +134,30 @@ export const GlobalHeaderShell = React.memo(
)}
+
+
{search && (
{search}
)} + {help && ( +
+ {help} +
+ )} {actions && (
{actions}
)} + {userMenu && ( +
+ {userMenu} +
+ )}
); diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx new file mode 100644 index 0000000000000..43452394bfb91 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx @@ -0,0 +1,94 @@ +/* + * 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 type { ReactNode } from 'react'; +import React from 'react'; +import { css } from '@emotion/react'; +import { useEuiTheme } from '@elastic/eui'; + +const ACTION_BUTTON_SIZE = 32; + +const baseStyles = css({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + boxSizing: 'border-box', + width: ACTION_BUTTON_SIZE, + height: ACTION_BUTTON_SIZE, + padding: 0, + background: 'transparent', + cursor: 'pointer', + '&:hover': { background: 'var(--action-btn-hover)' }, + '&:focus-visible': { + outline: '2px solid var(--action-btn-focus)', + outlineOffset: -2, + }, +}); + +const borderedStyles = css({ + border: '1px solid var(--action-btn-border)', +}); + +const plainStyles = css({ + border: 'none', +}); + +export interface HeaderActionButtonProps + extends Pick { + variant: 'bordered' | 'plain'; + children: ReactNode; + onClick: () => void; + 'aria-label': string; + 'data-test-subj'?: string; +} + +export const HeaderActionButton = React.forwardRef( + ( + { + variant, + children, + onClick, + 'aria-label': ariaLabel, + 'aria-expanded': ariaExpanded, + 'aria-haspopup': ariaHaspopup, + 'data-test-subj': dataTestSubj, + }, + ref + ) => { + const { euiTheme } = useEuiTheme(); + + return ( + + ); + } +); + +HeaderActionButton.displayName = 'HeaderActionButton'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/help_button.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/help_button.tsx new file mode 100644 index 0000000000000..60790573135b1 --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/help_button.tsx @@ -0,0 +1,40 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { HeaderHelpMenu } from '../../shared/header_help_menu'; +import { HeaderActionButton } from './header_action_button'; + +const HELP_ARIA_LABEL = i18n.translate('core.ui.chrome.headerGlobalNav.helpMenuButtonAriaLabel', { + defaultMessage: 'Help menu', +}); + +export const HelpButton = React.memo(() => { + const renderButton = useCallback( + ({ isOpen, toggleMenu }: { isOpen: boolean; toggleMenu: () => void }) => ( + + + + ), + [] + ); + + return ; +}); + +HelpButton.displayName = 'HelpButton'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts index fcdd0fa6bd0f4..cead996b4ffc2 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/index.ts @@ -10,3 +10,5 @@ export { GlobalHeader } from './global_header'; export { GlobalHeaderShell } from './global_header_shell'; export type { GlobalHeaderShellProps } from './global_header_shell'; +export { HeaderActionButton } from './header_action_button'; +export type { HeaderActionButtonProps } from './header_action_button'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/index.ts b/src/core/packages/chrome/browser-components/src/chrome_next/index.ts index 222d68d187f29..f5eda0f6adcd7 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/index.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/index.ts @@ -9,5 +9,5 @@ export { AppHeader, AppHeaderShell } from './app_header'; export type { AppHeaderShellProps } from './app_header'; -export { GlobalHeader, GlobalHeaderShell } from './global_header'; -export type { GlobalHeaderShellProps } from './global_header'; +export { GlobalHeader, GlobalHeaderShell, HeaderActionButton } from './global_header'; +export type { GlobalHeaderShellProps, HeaderActionButtonProps } from './global_header'; diff --git a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts index a698651d4c304..6fc7071f1d1f1 100644 --- a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts +++ b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { ReactNode } from 'react'; import { useMemo } from 'react'; import type { Observable } from 'rxjs'; import { debounceTime, map } from 'rxjs'; @@ -250,6 +251,12 @@ export function useNextHeader(): ChromeNextHeaderConfig | undefined { return useObservable(config$, undefined); } +export function useUserMenu(): ReactNode { + const chrome = useChromeService(); + const content$ = useMemo(() => chrome.next.userMenu.get$(), [chrome]); + return useObservable(content$, null); +} + /** Returns whether the next-chrome experience is enabled via feature flag. */ export function useIsNextChrome(): boolean { const { featureFlags } = useChromeComponentsDeps(); diff --git a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx index 1446bf56cfd96..3a5ea57b98b86 100644 --- a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx +++ b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx @@ -43,7 +43,11 @@ const createCustomLink = ( ); -export const HeaderHelpMenu = () => { +interface HeaderHelpMenuProps { + renderButton?: (props: { isOpen: boolean; toggleMenu: () => void }) => React.ReactNode; +} + +export const HeaderHelpMenu = ({ renderButton }: HeaderHelpMenuProps = {}) => { const [isOpen, setIsOpen] = useState(false); const closeMenu = useCallback(() => setIsOpen(false), []); const toggleMenu = useCallback(() => setIsOpen((prev) => !prev), []); @@ -157,7 +161,9 @@ export const HeaderHelpMenu = () => { ); } - const button = ( + const button = renderButton ? ( + renderButton({ isOpen, toggleMenu }) + ) : ( ; }; userMenu: ChromeNext['userMenu'] & { - get$(): Observable; + get$(): Observable; }; spaceSelector: ChromeNext['spaceSelector'] & { get$(): Observable; diff --git a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts index 10b5759fe897c..247f7625c0571 100644 --- a/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts +++ b/src/core/packages/chrome/browser-internal/src/state/chrome_state.ts @@ -22,7 +22,6 @@ import type { ChromeNextAiButton, ChromeNextGlobalSearchConfig, ChromeNextSpaceSelectorConfig, - ChromeNextUserMenuConfig, ChromeUserBanner, } from '@kbn/core-chrome-browser'; import type { AppMenuConfig } from '@kbn/core-chrome-app-menu-components'; @@ -70,7 +69,7 @@ export interface ChromeState { appMenu: State; aiButton: State>; globalSearch: State; - userMenu: State; + userMenu: State; spaceSelector: State; /** Help system */ @@ -117,7 +116,7 @@ export function createChromeState({ application, docLinks }: ChromeStateDeps): C const globalFooter = createState(null); const aiButton = createState>(new Set()); const globalSearch = createState(undefined); - const userMenu = createState(undefined); + const userMenu = createState(null); const spaceSelector = createState(undefined); const customNavLink = createState(undefined); diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index 249f13f19f0b7..020b039da4d08 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -9,13 +9,13 @@ import { BehaviorSubject, of } from 'rxjs'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; +import type { ReactNode } from 'react'; import type { ChromeBadge, ChromeBreadcrumb, ChromeNextHeaderConfig, ChromeNextGlobalSearchConfig, ChromeNextSpaceSelectorConfig, - ChromeNextUserMenuConfig, } from '@kbn/core-chrome-browser'; import type { InternalChromeSetup, @@ -35,7 +35,7 @@ const createStartContractMock = () => { const nextGlobalSearchState$ = new BehaviorSubject( undefined ); - const nextUserMenuState$ = new BehaviorSubject(undefined); + const nextUserMenuState$ = new BehaviorSubject(null); const nextSpaceSelectorState$ = new BehaviorSubject( undefined ); @@ -133,8 +133,8 @@ const createStartContractMock = () => { }), userMenu: lazyObject({ get$: jest.fn().mockReturnValue(nextUserMenuState$), - set: jest.fn((config?: ChromeNextUserMenuConfig) => { - nextUserMenuState$.next(config); + set: jest.fn((content?: ReactNode) => { + nextUserMenuState$.next(content ?? null); }), }), spaceSelector: lazyObject({ diff --git a/src/core/packages/chrome/browser/index.ts b/src/core/packages/chrome/browser/index.ts index 580130955782d..ee8b7ac969b3f 100644 --- a/src/core/packages/chrome/browser/index.ts +++ b/src/core/packages/chrome/browser/index.ts @@ -66,6 +66,4 @@ export type { ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderTab, ChromeNextSpaceSelectorConfig, - ChromeNextUserMenuConfig, - ChromeNextUserMenuItem, } from './src'; diff --git a/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts index 63c935fe3b070..7480157dca246 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts @@ -7,11 +7,11 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import type { ReactNode } from 'react'; import type { ChromeNextAiButton } from './ai_button'; import type { ChromeNextGlobalSearchConfig } from './global_search'; import type { ChromeNextHeaderConfig } from './header'; import type { ChromeNextSpaceSelectorConfig } from './space_selector'; -import type { ChromeNextUserMenuConfig } from './user_menu'; /** * Chrome-Next APIs: header configuration, AI button slot, global search, user menu, and space selector. @@ -48,11 +48,12 @@ export interface ChromeNext { }; userMenu: { /** - * Set the user menu configuration for the Chrome-Next sidenav. - * Chrome renders a user avatar in the sidenav footer with a popover listing the provided items. + * Set the user menu content for the Chrome-Next global header. + * The provided ReactNode is rendered as-is in the header's user menu slot. + * The consumer owns the full UI (avatar button, popover, menu items). * Pass `undefined` to remove. Global — persists across app changes. */ - set(config?: ChromeNextUserMenuConfig): void; + set(content?: ReactNode): void; }; spaceSelector: { /** diff --git a/src/core/packages/chrome/browser/src/chrome_next/index.ts b/src/core/packages/chrome/browser/src/chrome_next/index.ts index 7968c144add6b..3e6646906ba0b 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/index.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/index.ts @@ -20,4 +20,3 @@ export type { ChromeNextHeaderTab, } from './header'; export type { ChromeNextSpaceSelectorConfig } from './space_selector'; -export type { ChromeNextUserMenuConfig, ChromeNextUserMenuItem } from './user_menu'; diff --git a/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts b/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts index 4869c7e0098ec..7f403bb190f0b 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/user_menu.ts @@ -9,17 +9,4 @@ import type { ReactNode } from 'react'; -export interface ChromeNextUserMenuConfig { - label: string; - renderAvatar: () => ReactNode; - items: ChromeNextUserMenuItem[]; -} - -export interface ChromeNextUserMenuItem { - id: string; - label: string; - href?: string; - isExternal?: boolean; - 'data-test-subj'?: string; - onClick?: () => void; -} +export type ChromeNextUserMenu = ReactNode; diff --git a/src/core/packages/chrome/browser/src/index.ts b/src/core/packages/chrome/browser/src/index.ts index a59c61945a15e..cad9093c92bd8 100644 --- a/src/core/packages/chrome/browser/src/index.ts +++ b/src/core/packages/chrome/browser/src/index.ts @@ -72,6 +72,4 @@ export type { ChromeNextHeaderMetadataSlotItem, ChromeNextHeaderTab, ChromeNextSpaceSelectorConfig, - ChromeNextUserMenuConfig, - ChromeNextUserMenuItem, } from './chrome_next'; diff --git a/x-pack/platform/plugins/shared/security/moon.yml b/x-pack/platform/plugins/shared/security/moon.yml index 663a157dacc82..e8b015d136aac 100644 --- a/x-pack/platform/plugins/shared/security/moon.yml +++ b/x-pack/platform/plugins/shared/security/moon.yml @@ -104,6 +104,7 @@ dependsOn: - '@kbn/mock-idp-utils' - '@kbn/core-user-activity-server' - '@kbn/core-user-activity-server-mocks' + - '@kbn/core-chrome-browser-components' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.test.tsx b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.test.tsx index c5476dcbef1fd..f1a14d9c1a506 100644 --- a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.test.tsx @@ -291,12 +291,25 @@ describe('SecurityNavControl', () => { link3 -
- Dummy Component -
-
+ + + dummyComponent + + +
+
@@ -413,9 +426,10 @@ describe('SecurityNavControl', () => { link3 -
{ > Log out -
+
@@ -481,9 +495,10 @@ describe('SecurityNavControl', () => { tabindex="-1" >
-
{ > Log in -
+
diff --git a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx index 3b41fac1ab451..dfc660f563870 100644 --- a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx @@ -15,8 +15,8 @@ import { EuiLoadingSpinner, EuiPopover, } from '@elastic/eui'; -import type { FunctionComponent } from 'react'; -import React, { useState } from 'react'; +import type { FunctionComponent, ReactNode } from 'react'; +import React, { useCallback, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; @@ -59,48 +59,67 @@ const ContextMenuContent = ({ items, closePopover }: ContextMenuProps) => { ); }; +export interface SecurityNavControlRenderButtonProps { + isOpen: boolean; + toggleMenu: () => void; + avatar: ReactNode; +} + interface SecurityNavControlProps { editProfileUrl: string; logoutUrl: string; userMenuLinks$: Observable; + renderButton?: (props: SecurityNavControlRenderButtonProps) => NonNullable; + avatarSize?: 's' | 'm' | 'l'; } export const SecurityNavControl: FunctionComponent = ({ editProfileUrl, logoutUrl, userMenuLinks$, + renderButton, + avatarSize = 's', }) => { const userMenuLinks = useObservable(userMenuLinks$, []); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const userProfile = useUserProfile<{ avatar: UserProfileAvatarData }>('avatar,userSettings'); - const currentUser = useCurrentUser(); // User profiles do not exist for anonymous users so need to fetch current user as well + const currentUser = useCurrentUser(); const displayName = currentUser.value ? getUserDisplayName(currentUser.value) : ''; - const button = ( + const toggleMenu = useCallback( + () => setIsPopoverOpen((value) => (currentUser.value ? !value : false)), + [currentUser.value] + ); + + const avatar = userProfile.value ? ( + + ) : currentUser.value && userProfile.error ? ( + + ) : ( + + ); + + const button = renderButton ? ( + renderButton({ isOpen: isPopoverOpen, toggleMenu, avatar }) + ) : ( setIsPopoverOpen((value) => (currentUser.value ? !value : false))} + onClick={toggleMenu} data-test-subj="userMenuButton" style={{ lineHeight: 'normal' }} > - {userProfile.value ? ( - - ) : currentUser.value && userProfile.error ? ( - - ) : ( - - )} + {avatar} ); diff --git a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx index af24eb926241d..04f0b7b613d6c 100644 --- a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_service.tsx @@ -12,7 +12,7 @@ import type { Subscription } from 'rxjs'; import { BehaviorSubject, map, ReplaySubject, takeUntil } from 'rxjs'; import type { CoreStart } from '@kbn/core/public'; -import type { ChromeNextUserMenuItem } from '@kbn/core-chrome-browser'; +import { HeaderActionButton } from '@kbn/core-chrome-browser-components'; import { i18n } from '@kbn/i18n'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { @@ -21,11 +21,10 @@ import type { UserMenuLink, } from '@kbn/security-plugin-types-public'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; -import { UserAvatar, type UserProfileAvatarData } from '@kbn/user-profile-components'; import { SecurityNavControl } from './nav_control_component'; +import type { SecurityNavControlRenderButtonProps } from './nav_control_component'; import type { SecurityLicense } from '../../common'; -import { getUserDisplayName, isUserAnonymous } from '../../common/model'; import type { SecurityApiClients } from '../components'; import { AuthenticationProvider, SecurityApiClientsProvider } from '../components'; @@ -128,96 +127,17 @@ export class SecurityNavControlService { } private registerChromeNextUserMenu(core: CoreStart, authc: AuthenticationServiceSetup) { - const editProfileUrl = core.http.basePath.prepend('/security/account'); - const { userProfiles } = this.securityApiClients; - - Promise.all([ - authc.getCurrentUser(), - userProfiles - .getCurrent<{ avatar: UserProfileAvatarData }>({ dataPath: 'avatar' }) - .catch(() => null), - ]) - .then(([currentUser, userProfile]) => { - const userDisplayName = getUserDisplayName(currentUser); - const isAnonymous = isUserAnonymous(currentUser); - - const renderAvatar = () => - userProfile ? ( - - ) : ( - - ); - - const buildItems = (userMenuLinks: UserMenuLink[]): ChromeNextUserMenuItem[] => { - const items: ChromeNextUserMenuItem[] = []; - - const sorted = this.sortUserMenuLinks(userMenuLinks); - const hasCustomProfileLinks = sorted.some(({ setAsProfile }) => setAsProfile === true); - - if (!isAnonymous && !hasCustomProfileLinks) { - items.push({ - id: 'profileLink', - label: i18n.translate('xpack.security.navControlComponent.editProfileLinkText', { - defaultMessage: 'Edit profile', - }), - href: editProfileUrl, - 'data-test-subj': 'profileLink', - }); - } - - for (const link of sorted) { - if (!link.label || (!link.href && !link.onClick)) { - continue; - } - items.push({ - id: `userMenuLink__${link.label}`, - label: link.label, - ...(link.href && { href: link.href }), - ...(link.onClick && { - onClick: () => { - link.onClick?.(); - }, - }), - 'data-test-subj': `userMenuLink__${link.label}`, - }); - } - - items.push({ - id: 'logoutLink', - label: isAnonymous - ? i18n.translate('xpack.security.navControlComponent.loginLinkText', { - defaultMessage: 'Log in', - }) - : i18n.translate('xpack.security.navControlComponent.logoutLinkText', { - defaultMessage: 'Log out', - }), - href: this.logoutUrl, - 'data-test-subj': 'logoutLink', - }); - - return items; - }; - - const setConfig = (userMenuLinks: UserMenuLink[]) => { - core.chrome.next.userMenu.set({ - label: userDisplayName, - renderAvatar, - items: buildItems(userMenuLinks), - }); - }; - - setConfig(this.userMenuLinks$.value); - - this.userMenuLinks$.pipe(takeUntil(this.stop$)).subscribe((links) => setConfig(links)); - }) - .catch(() => { - // Chrome Next user menu unavailable — legacy nav control still active - }); + core.chrome.next.userMenu.set( + + + + ); } private sortUserMenuLinks(userMenuLinks: UserMenuLink[]) { @@ -225,6 +145,28 @@ export class SecurityNavControlService { } } +const ACCOUNT_MENU_ARIA_LABEL = i18n.translate( + 'xpack.security.navControlComponent.accountMenuAriaLabel', + { defaultMessage: 'Account menu' } +); + +const chromeNextUserMenuButton = ({ + isOpen, + toggleMenu, + avatar, +}: SecurityNavControlRenderButtonProps) => ( + + {avatar} + +); + export interface ProvidersProps { authc: AuthenticationServiceSetup; services: CoreStart; diff --git a/x-pack/platform/plugins/shared/security/tsconfig.json b/x-pack/platform/plugins/shared/security/tsconfig.json index 73ba4c2bceb05..b3298c195527f 100644 --- a/x-pack/platform/plugins/shared/security/tsconfig.json +++ b/x-pack/platform/plugins/shared/security/tsconfig.json @@ -97,7 +97,8 @@ "@kbn/scout", "@kbn/mock-idp-utils", "@kbn/core-user-activity-server", - "@kbn/core-user-activity-server-mocks" + "@kbn/core-user-activity-server-mocks", + "@kbn/core-chrome-browser-components" ], "exclude": ["target/**/*"] } From 18d5897cae62c967832cdfbe13a1ff6b52789c1f Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 14 Apr 2026 13:05:52 +0200 Subject: [PATCH 28/77] [Chrome Next] Add search button to global header (#262997) ## Summary Add a search button to the Chrome-Next global header that triggers the global search modal via the existing `chrome.next.globalSearch` API. - Styled as a fake search input (bordered button with magnifying glass icon, "Find content..." placeholder, and keyboard shortcut badge) - Registers a global keyboard shortcut handler (Cmd/Ctrl + /) to open search - Collapses to an icon-only square button on smaller screens via CSS media query - Extracts shared header button base styles (`headerButtonBaseStyles`, `headerButtonBorderedStyles`, `useHeaderButtonStyleVars`) so both `HeaderActionButton` and `SearchButton` share one source of truth Screenshot 2026-04-14 at 12 04 11 --- .../chrome/browser-components/moon.yml | 1 + .../global_header/global_header.tsx | 2 + .../global_header/global_header_shell.tsx | 6 +- .../global_header/header_action_button.tsx | 49 ++++---- .../chrome_next/global_header/help_button.tsx | 2 +- .../global_header/search_button.tsx | 115 ++++++++++++++++++ .../src/shared/chrome_hooks.ts | 7 ++ .../src/shared/header_help_menu.tsx | 2 +- .../chrome/browser-components/tsconfig.json | 1 + .../shared/kbn-shared-ux-utility/index.ts | 1 + .../src/browser/index.ts | 2 + .../src/browser/use_keyboard_shortcut.ts | 67 ++++++++++ .../public/components/types.ts | 2 +- 13 files changed, 229 insertions(+), 28 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/global_header/search_button.tsx create mode 100644 src/platform/packages/shared/kbn-shared-ux-utility/src/browser/use_keyboard_shortcut.ts diff --git a/src/core/packages/chrome/browser-components/moon.yml b/src/core/packages/chrome/browser-components/moon.yml index a64b7b61cc827..e7419f7a39a00 100644 --- a/src/core/packages/chrome/browser-components/moon.yml +++ b/src/core/packages/chrome/browser-components/moon.yml @@ -50,6 +50,7 @@ dependsOn: - '@kbn/test-jest-helpers' - '@kbn/use-observable' - '@kbn/core-chrome-layout-components' + - '@kbn/shared-ux-utility' tags: - shared-browser - package diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx index 5120afd082471..da9c8f1d21cff 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header.tsx @@ -11,12 +11,14 @@ import React from 'react'; import { GlobalHeaderShell } from './global_header_shell'; import { GlobalHeaderLogo } from './global_header_logo'; import { AiButtonSlot } from './ai_button_slot'; +import { SearchButton } from './search_button'; import { HelpButton } from './help_button'; import { useUserMenu } from '../../shared/chrome_hooks'; export const GlobalHeader = React.memo(() => ( } + search={} help={} actions={} userMenu={useUserMenu()} diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx index 55063ec1e0ee0..169afc1c62d32 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx @@ -11,7 +11,7 @@ import type { ReactNode } from 'react'; import { useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; import { COLLAPSED_WIDTH, EXPANDED_WIDTH } from '@kbn/core-chrome-navigation'; -import { useSideNavCollapsed } from '@kbn/core-chrome-browser-hooks'; +import { useSideNavWidth } from '@kbn/core-chrome-browser-hooks'; import React, { useMemo } from 'react'; const GLOBAL_HEADER_HEIGHT_PX = 48; @@ -118,9 +118,9 @@ const useGlobalHeaderStyles = () => { export const GlobalHeaderShell = React.memo( ({ logo, switcher, search, help, actions, userMenu }) => { - const { isCollapsed } = useSideNavCollapsed(); + const sideNavWidth = useSideNavWidth(); const styles = useGlobalHeaderStyles(); - const logoWidth = isCollapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; + const logoWidth = sideNavWidth <= COLLAPSED_WIDTH ? COLLAPSED_WIDTH : EXPANDED_WIDTH; return (
diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx index 43452394bfb91..0eb804b8d8978 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/header_action_button.tsx @@ -12,33 +12,44 @@ import React from 'react'; import { css } from '@emotion/react'; import { useEuiTheme } from '@elastic/eui'; -const ACTION_BUTTON_SIZE = 32; - -const baseStyles = css({ +export const headerButtonBaseStyles = css({ display: 'inline-flex', alignItems: 'center', - justifyContent: 'center', boxSizing: 'border-box', - width: ACTION_BUTTON_SIZE, - height: ACTION_BUTTON_SIZE, - padding: 0, + height: 32, background: 'transparent', cursor: 'pointer', - '&:hover': { background: 'var(--action-btn-hover)' }, + '&:hover': { background: 'var(--header-btn-hover)' }, '&:focus-visible': { - outline: '2px solid var(--action-btn-focus)', + outline: '2px solid var(--header-btn-focus)', outlineOffset: -2, }, }); -const borderedStyles = css({ - border: '1px solid var(--action-btn-border)', +export const headerButtonBorderedStyles = css({ + border: '1px solid var(--header-btn-border)', }); const plainStyles = css({ border: 'none', }); +const squareStyles = css({ + width: 32, + padding: 0, + justifyContent: 'center', +}); + +export const useHeaderButtonStyleVars = () => { + const { euiTheme } = useEuiTheme(); + return { + '--header-btn-border': euiTheme.colors.borderBasePlain, + '--header-btn-hover': euiTheme.colors.backgroundBaseInteractiveHover, + '--header-btn-focus': euiTheme.colors.primary, + borderRadius: euiTheme.border.radius.medium, + } as React.CSSProperties; +}; + export interface HeaderActionButtonProps extends Pick { variant: 'bordered' | 'plain'; @@ -61,7 +72,7 @@ export const HeaderActionButton = React.forwardRef { - const { euiTheme } = useEuiTheme(); + const styleVars = useHeaderButtonStyleVars(); return ( + ); +}); + +SearchButton.displayName = 'SearchButton'; diff --git a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts index 6fc7071f1d1f1..e547ce2cf715b 100644 --- a/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts +++ b/src/core/packages/chrome/browser-components/src/shared/chrome_hooks.ts @@ -15,6 +15,7 @@ import type { ChromeBreadcrumb, ChromeNavControl, ChromeNavLink, + ChromeNextGlobalSearchConfig, ChromeNextHeaderConfig, } from '@kbn/core-chrome-browser'; import type { ApplicationStart } from '@kbn/core-application-browser'; @@ -251,6 +252,12 @@ export function useNextHeader(): ChromeNextHeaderConfig | undefined { return useObservable(config$, undefined); } +export function useGlobalSearch(): ChromeNextGlobalSearchConfig | undefined { + const chrome = useChromeService(); + const config$ = useMemo(() => chrome.next.globalSearch.get$(), [chrome]); + return useObservable(config$, undefined); +} + export function useUserMenu(): ReactNode { const chrome = useChromeService(); const content$ = useMemo(() => chrome.next.userMenu.get$(), [chrome]); diff --git a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx index 3a5ea57b98b86..c2f98cfb97065 100644 --- a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx +++ b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx @@ -44,7 +44,7 @@ const createCustomLink = ( ); interface HeaderHelpMenuProps { - renderButton?: (props: { isOpen: boolean; toggleMenu: () => void }) => React.ReactNode; + renderButton?: (props: { isOpen: boolean; toggleMenu: () => void }) => NonNullable; } export const HeaderHelpMenu = ({ renderButton }: HeaderHelpMenuProps = {}) => { diff --git a/src/core/packages/chrome/browser-components/tsconfig.json b/src/core/packages/chrome/browser-components/tsconfig.json index e2ecb2538e24a..c30af93d195c3 100644 --- a/src/core/packages/chrome/browser-components/tsconfig.json +++ b/src/core/packages/chrome/browser-components/tsconfig.json @@ -50,5 +50,6 @@ "@kbn/test-jest-helpers", "@kbn/use-observable", "@kbn/core-chrome-layout-components", + "@kbn/shared-ux-utility", ] } diff --git a/src/platform/packages/shared/kbn-shared-ux-utility/index.ts b/src/platform/packages/shared/kbn-shared-ux-utility/index.ts index dcb0854fe38f0..3b28ade9a5cda 100644 --- a/src/platform/packages/shared/kbn-shared-ux-utility/index.ts +++ b/src/platform/packages/shared/kbn-shared-ux-utility/index.ts @@ -12,3 +12,4 @@ export { getClosestLink, hasActiveModifierKey } from './src/utils'; export { withSuspense, type WithSuspenseExtendedDeps } from './src/with_suspense'; export { dynamic, type DynamicOptions } from './src/dynamic'; export { isMac, isWindows, isLinux, getPlatform, type Platform } from './src/browser'; +export { useKeyboardShortcut, type KeyboardShortcut } from './src/browser'; diff --git a/src/platform/packages/shared/kbn-shared-ux-utility/src/browser/index.ts b/src/platform/packages/shared/kbn-shared-ux-utility/src/browser/index.ts index d56221845f8e2..866a5e623ee91 100644 --- a/src/platform/packages/shared/kbn-shared-ux-utility/src/browser/index.ts +++ b/src/platform/packages/shared/kbn-shared-ux-utility/src/browser/index.ts @@ -9,3 +9,5 @@ export { isMac, isWindows, isLinux, getPlatform } from './platform'; export type { Platform } from './platform'; +export { useKeyboardShortcut } from './use_keyboard_shortcut'; +export type { KeyboardShortcut } from './use_keyboard_shortcut'; diff --git a/src/platform/packages/shared/kbn-shared-ux-utility/src/browser/use_keyboard_shortcut.ts b/src/platform/packages/shared/kbn-shared-ux-utility/src/browser/use_keyboard_shortcut.ts new file mode 100644 index 0000000000000..d6c3c606d42dd --- /dev/null +++ b/src/platform/packages/shared/kbn-shared-ux-utility/src/browser/use_keyboard_shortcut.ts @@ -0,0 +1,67 @@ +/* + * 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 { isMac } from './platform'; + +export interface KeyboardShortcut { + key: string; + /** Cmd on Mac, Ctrl on other platforms. */ + meta?: boolean; + shift?: boolean; + alt?: boolean; + /** Literal Ctrl on all platforms (distinct from meta on Mac). */ + ctrl?: boolean; +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return true; + return target.isContentEditable; +} + +export function useKeyboardShortcut( + shortcut: KeyboardShortcut | undefined, + callback: (() => void) | undefined +) { + useEffect(() => { + if (!shortcut || !callback) return; + + const target = shortcut.key.toLowerCase(); + const wantMeta = shortcut.meta ?? false; + const wantShift = shortcut.shift ?? false; + const wantAlt = shortcut.alt ?? false; + const wantCtrl = shortcut.ctrl ?? false; + + const handler = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== target) return; + if (isEditableTarget(e.target)) return; + + if (wantShift !== e.shiftKey) return; + if (wantAlt !== e.altKey) return; + + if (isMac) { + if (wantMeta !== e.metaKey) return; + if (wantCtrl !== e.ctrlKey) return; + } else { + // On non-Mac, meta and ctrl both map to the physical Ctrl key. + // They cannot be distinguished, so either flag claims e.ctrlKey. + const wantEither = wantMeta || wantCtrl; + if (wantEither !== e.ctrlKey) return; + if (e.metaKey) return; + } + + e.preventDefault(); + callback(); + }; + + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [shortcut, callback]); +} diff --git a/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts b/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts index 86c000e364f5f..a033ea348dbc5 100644 --- a/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts +++ b/x-pack/platform/plugins/private/global_search_bar/public/components/types.ts @@ -16,7 +16,7 @@ export const SEARCH_MODAL_SELECTOR_PREFIX = 'chromeProjectNextSearchModal'; export const SEARCH_MODAL_HEIGHT = 50; export const SEARCH_MODAL_WIDTH = 800; export const SEARCH_MODAL_ROW_HEIGHT = 68; -export const SEARCH_MODAL_KEYBOARD_SHORTCUT = 'k'; +export const SEARCH_MODAL_KEYBOARD_SHORTCUT = '/'; /* @internal */ export interface SearchProps { From 57cad6b3dacd9ebfcab2cbb4718d5b0c93b28e9f Mon Sep 17 00:00:00 2001 From: Krzysztof Kowalczyk Date: Tue, 14 Apr 2026 13:18:06 +0200 Subject: [PATCH 29/77] Lint fix --- .../browser-components/src/shared/header_help_menu.tsx | 5 ++++- .../security/public/nav_control/nav_control_component.tsx | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx index c2f98cfb97065..77bee6bfa2ba8 100644 --- a/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx +++ b/src/core/packages/chrome/browser-components/src/shared/header_help_menu.tsx @@ -44,7 +44,10 @@ const createCustomLink = ( ); interface HeaderHelpMenuProps { - renderButton?: (props: { isOpen: boolean; toggleMenu: () => void }) => NonNullable; + renderButton?: (props: { + isOpen: boolean; + toggleMenu: () => void; + }) => NonNullable; } export const HeaderHelpMenu = ({ renderButton }: HeaderHelpMenuProps = {}) => { diff --git a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx index dfc660f563870..c74db037b000d 100644 --- a/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/platform/plugins/shared/security/public/nav_control/nav_control_component.tsx @@ -129,7 +129,7 @@ export const SecurityNavControl: FunctionComponent = ({ .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) .map(({ label, iconType, href, onClick }: UserMenuLink) => ({ name: label, - icon: , + icon: , href, onClick, 'data-test-subj': `userMenuLink__${label}`, @@ -148,7 +148,7 @@ export const SecurityNavControl: FunctionComponent = ({ defaultMessage="Edit profile" /> ), - icon: , + icon: , href: editProfileUrl, onClick: () => { setIsPopoverOpen(false); @@ -172,7 +172,7 @@ export const SecurityNavControl: FunctionComponent = ({ defaultMessage="Log out" /> ), - icon: , + icon: , href: logoutUrl, 'data-test-subj': 'logoutLink', }); From d7697d37fdbee38ab2c7af00a1621f5fca256572 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 14 Apr 2026 19:28:56 +0200 Subject: [PATCH 30/77] Chrome-next logo, separators, and home nav item (#263093) --- .../global_header/global_header_logo.tsx | 27 +++++++++- .../global_header/global_header_shell.tsx | 2 +- .../project/sidenav/navigation/navigation.tsx | 5 +- .../navigation/to_navigation_items.tsx | 50 +++++++++---------- .../src/shared/loading_indicator.tsx | 8 ++- .../navigation/packaging/react/types.ts | 2 + .../navigation/src/components/navigation.tsx | 38 ++++++++++---- .../navigation/src/hooks/use_navigation.ts | 2 +- 8 files changed, 93 insertions(+), 41 deletions(-) diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx index 026daf85040dd..d04b96f70645e 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_logo.tsx @@ -8,22 +8,45 @@ */ import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; import React from 'react'; import { useProjectHome, useBasePath, useCustomBranding } from '../../shared/chrome_hooks'; import { LoadingIndicator } from '../../shared/loading_indicator'; +import { headerButtonBaseStyles, useHeaderButtonStyleVars } from './header_action_button'; const LOGO_ARIA_LABEL = i18n.translate('core.ui.chrome.globalHeader.logoAriaLabel', { defaultMessage: 'Elastic home', }); +const logoLinkStyles = css` + ${headerButtonBaseStyles}; + width: 32px; + justify-content: center; + border: none; + text-decoration: none; + color: inherit; + + svg { + width: 20px; + height: 20px; + } +`; + export const GlobalHeaderLogo = React.memo(() => { const basePath = useBasePath(); const homeHref = basePath.prepend(useProjectHome()); const { logo: customLogo } = useCustomBranding(); + const styleVars = useHeaderButtonStyleVars(); return ( - - + + ); }); diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx index 169afc1c62d32..5f727a8d86d76 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/global_header/global_header_shell.tsx @@ -96,7 +96,7 @@ const useGlobalHeaderStyles = () => { const separator = css` width: 1px; - height: 20px; + height: 24px; flex-shrink: 0; background: ${euiTheme.colors.borderBaseSubdued}; `; diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx index de3343b6ee4ca..f31d195ffe14d 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/navigation.tsx @@ -28,6 +28,7 @@ export interface ChromeNavigationProps { export const Navigation = (props: ChromeNavigationProps) => { const state = useNavigationItems(); + const isNextChrome = useIsNextChrome(); if (!state) { return null; @@ -44,6 +45,7 @@ export const Navigation = (props: ChromeNavigationProps) => { setWidth={props.setWidth} onToggleCollapsed={props.onToggleCollapsed} activeItemId={activeItemId} + showTopSeparator={isNextChrome} data-test-subj={classnames(`${solutionId}SideNav`, 'projectSideNav', 'projectSideNavV2')} /> @@ -71,7 +73,8 @@ const useNavigationItems = (): NavigationState | null => { const { navItems, logoItem, activeItemId } = toNavigationItems( nav.navigationTree, nav.activeNodes, - panelStateManager + panelStateManager, + isNextChrome ); return { solutionId: nav.solutionId, diff --git a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_navigation_items.tsx b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_navigation_items.tsx index ad798056c0d78..a444dc6a9d85e 100644 --- a/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_navigation_items.tsx +++ b/src/core/packages/chrome/browser-components/src/project/sidenav/navigation/to_navigation_items.tsx @@ -28,7 +28,7 @@ import { isActiveFromUrl } from './utils/is_active_from_url'; const SKIP_WARNINGS = process.env.NODE_ENV === 'production'; export interface NavigationItems { - logoItem: SideNavLogo; + logoItem?: SideNavLogo; navItems: NavigationStructure; activeItemId?: string; } @@ -58,12 +58,11 @@ export interface NavigationItems { export const toNavigationItems = ( navigationTree: NavigationTreeDefinitionUI, activeNodes: ChromeProjectNavigationNode[][], - panelStateManager: PanelStateManager + panelStateManager: PanelStateManager, + isNextChrome: boolean = false ): NavigationItems => { - // HACK: extract the logo, primary and footer nodes from the navigation tree - let logoNode: ChromeProjectNavigationNode | null = null; - let primaryNodes: ChromeProjectNavigationNode[] = []; - let footerNodes: ChromeProjectNavigationNode[] = []; + let primaryNodes: ChromeProjectNavigationNode[] = navigationTree.body; + const footerNodes: ChromeProjectNavigationNode[] = navigationTree.footer ?? []; let deepestActiveItemId: string | undefined; let currentActiveItemIdLevel = -1; @@ -102,28 +101,31 @@ export const toNavigationItems = ( ); }; - primaryNodes = navigationTree.body; - footerNodes = navigationTree.footer ?? []; + let logoItem: SideNavLogo | undefined; const homeNodeIndex = primaryNodes.findIndex((node) => node.renderAs === 'home'); if (homeNodeIndex !== -1) { - logoNode = primaryNodes[homeNodeIndex]; - primaryNodes = primaryNodes.filter((_, index) => index !== homeNodeIndex); // Remove the logo node from primary items - maybeMarkActive(logoNode, 0); + const homeNode = primaryNodes[homeNodeIndex]; + maybeMarkActive(homeNode, 0); + + if (isNextChrome) { + primaryNodes[homeNodeIndex] = { ...homeNode, title: 'Home', icon: 'home' }; + } else { + primaryNodes = primaryNodes.filter((_, i) => i !== homeNodeIndex); + logoItem = { + href: warnIfMissing(homeNode, 'href', '/missing-href-😭'), + iconType: getIcon(homeNode), + id: warnIfMissing(homeNode, 'id', 'kibana'), + label: warnIfMissing(homeNode, 'title', 'Kibana'), + 'data-test-subj': getTestSubj(homeNode, ['nav-item-home']), + }; + } } else { warnOnce( `No "home" node found in primary nodes. There should be a logo node with solution logo, name and home page href. renderAs: "home" is expected.` ); } - const logoItem: SideNavLogo = { - href: warnIfMissing(logoNode, 'href', '/missing-href-😭'), - iconType: getIcon(logoNode), - id: warnIfMissing(logoNode, 'id', 'kibana'), - label: warnIfMissing(logoNode, 'title', 'Kibana'), - 'data-test-subj': logoNode ? getTestSubj(logoNode, ['nav-item-home']) : undefined, - }; - const toMenuItem = (navNode: ChromeProjectNavigationNode): MenuItem[] | MenuItem | null => { if (!navNode) return null; @@ -358,13 +360,12 @@ function warnAboutDuplicates( } function warnAboutDuplicateIcons( - logoItem: SideNavLogo, + logoItem: SideNavLogo | undefined, primaryItems: MenuItem[], footerItems: MenuItem[] ) { if (SKIP_WARNINGS) return; - // Collect all items with icons (only logo + primary items, excluding fallback) - const icons = [logoItem, ...primaryItems, ...footerItems] + const icons = [...(logoItem ? [logoItem] : []), ...primaryItems, ...footerItems] .filter( (item) => item.iconType && item.iconType !== FALLBACK_ICON && typeof item.iconType === 'string' @@ -379,13 +380,12 @@ function warnAboutDuplicateIcons( } function warnAboutDuplicateIds( - logoItem: SideNavLogo, + logoItem: SideNavLogo | undefined, primaryItems: MenuItem[], footerItems: MenuItem[] ) { if (SKIP_WARNINGS) return; - // Collect all IDs from all items, including secondary menu items - let allIds: string[] = [logoItem.id]; + let allIds: string[] = logoItem ? [logoItem.id] : []; // Helper to extract IDs from menu items including their secondary sections const collectIds = (items: MenuItem[]) => { diff --git a/src/core/packages/chrome/browser-components/src/shared/loading_indicator.tsx b/src/core/packages/chrome/browser-components/src/shared/loading_indicator.tsx index 0fad5e798b159..b1ca71e275a8c 100644 --- a/src/core/packages/chrome/browser-components/src/shared/loading_indicator.tsx +++ b/src/core/packages/chrome/browser-components/src/shared/loading_indicator.tsx @@ -16,9 +16,14 @@ import { useIsLoading } from './chrome_hooks'; export interface LoadingIndicatorProps { showAsBar?: boolean; customLogo?: string; + elasticLogoColor?: string; } -export const LoadingIndicator = ({ showAsBar = false, customLogo }: LoadingIndicatorProps) => { +export const LoadingIndicator = ({ + showAsBar = false, + customLogo, + elasticLogoColor, +}: LoadingIndicatorProps) => { const isLoading = useIsLoading(); const loadingSubj = isLoading ? 'globalLoadingIndicator' : 'globalLoadingIndicator-hidden'; @@ -42,6 +47,7 @@ export const LoadingIndicator = ({ showAsBar = false, customLogo }: LoadingIndic void; /** Callback fired when the collapse button is toggled. Omit to hide the toggle button. */ onToggleCollapsed?: (isCollapsed: boolean) => void; + /** When true, renders a centered horizontal separator at the top of the side nav. */ + showTopSeparator?: boolean; /** Content to display inside the side panel footer. */ sidePanelFooter?: ReactNode; /** Optional `data-test-subj` attribute for testing purposes. */ diff --git a/src/core/packages/chrome/navigation/src/components/navigation.tsx b/src/core/packages/chrome/navigation/src/components/navigation.tsx index 9a322be8e69d1..ed4f32b1aa00b 100644 --- a/src/core/packages/chrome/navigation/src/components/navigation.tsx +++ b/src/core/packages/chrome/navigation/src/components/navigation.tsx @@ -11,7 +11,7 @@ import React, { useState, type ReactNode } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { useIsWithinBreakpoints } from '@elastic/eui'; +import { useEuiTheme, useIsWithinBreakpoints } from '@elastic/eui'; import type { NavigationStructure, SideNavLogo, MenuItem, SecondaryMenuItem } from '../../types'; import { @@ -29,6 +29,7 @@ import { useLayoutWidth } from '../hooks/use_layout_width'; import { useNavigation } from '../hooks/use_navigation'; import { useNewItems } from '../hooks/use_new_items'; import { useResponsiveMenu } from '../hooks/use_responsive_menu'; +import { getHighContrastSeparator } from '../hooks/use_high_contrast_mode_styles'; const navigationWrapperStyles = css` display: flex; @@ -50,7 +51,7 @@ export interface NavigationProps { /** * The logo object containing the route ID, href, label, and type. */ - logo: SideNavLogo; + logo?: SideNavLogo; /** * Required by the grid layout to set the width of the navigation slot. */ @@ -70,6 +71,11 @@ export interface NavigationProps { * (optional) Content to display inside the side panel footer. */ sidePanelFooter?: ReactNode; + /** + * When true, renders a centered horizontal separator at the top of the side nav, + * between the global header and the logo/primary menu. + */ + showTopSeparator?: boolean; /** * (optional) data-test-subj attribute for testing purposes. */ @@ -84,11 +90,20 @@ export const Navigation = ({ onItemClick, onToggleCollapsed, setWidth, + showTopSeparator = false, sidePanelFooter, ...rest }: NavigationProps) => { const forcedCollapsed = useIsWithinBreakpoints(['xs', 's']); const isCollapsed = forcedCollapsed || isCollapsedProp; + const euiThemeContext = useEuiTheme(); + + const topSeparatorStyles = css` + position: relative; + flex-shrink: 0; + ${getHighContrastSeparator(euiThemeContext, { side: 'bottom' })} + `; + const popoverItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverItem`; const popoverFooterItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-popoverFooterItem`; const sidePanelItemPrefix = `${NAVIGATION_SELECTOR_PREFIX}-sidePanelItem`; @@ -100,7 +115,7 @@ export const Navigation = ({ visuallyActiveSubpageId, isSidePanelOpen, openerNode, - } = useNavigation(isCollapsed, items, logo.id, activeItemId); + } = useNavigation(isCollapsed, items, logo?.id, activeItemId); const [isAnyPopoverLocked, setIsAnyPopoverLocked] = useState(false); @@ -131,13 +146,16 @@ export const Navigation = ({ id={NAVIGATION_ROOT_SELECTOR} > - onItemClick?.(logo)} - {...logo} - /> + {showTopSeparator &&
} + {logo && ( + onItemClick?.(logo)} + {...logo} + /> + )} {({ mainNavigationInstructionsId }) => ( diff --git a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts index 01620f070cfe5..bb30c12bf4ceb 100644 --- a/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts +++ b/src/core/packages/chrome/navigation/src/hooks/use_navigation.ts @@ -39,7 +39,7 @@ interface NavigationState { export const useNavigation = ( isCollapsed: boolean, items: NavigationStructure, - logoId: string, + logoId: string | undefined, activeItemId?: string ) => { const { primaryItem, secondaryItem, isLogoActive } = useMemo( From 7db5a624ea510200c13aba5a5fc3e41b77b1eada Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 15 Apr 2026 14:29:29 +0200 Subject: [PATCH 31/77] wip polish title styles --- .../src/chrome_next/app_header/app_header.tsx | 6 +-- .../app_header/app_header_shell.tsx | 38 +++++++++---- .../chrome_next/app_header/back_button.tsx | 8 +-- .../src/chrome_next/app_header/title_area.tsx | 54 +++++++++++++++++++ 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/app_header/title_area.tsx diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx index dd21326aa47a5..d861ecf3b8d7f 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx @@ -10,15 +10,13 @@ import React from 'react'; import { AppHeaderShell } from './app_header_shell'; import { AppBadges } from './app_badges'; -import { BackButton } from './back_button'; -import { AppTitle } from './app_title'; +import { TitleArea } from './title_area'; import { GlobalActions } from './global_actions'; import { AppMenu } from './app_menu'; export const AppHeader = React.memo(() => ( } - title={} + title={} badges={} titleActions={} trailing={} diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx index 9136bb15813b2..df76c27b63b87 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx @@ -13,11 +13,9 @@ import { css } from '@emotion/react'; import React, { useMemo } from 'react'; import { useReportTopBarHeight } from './hooks'; -/** Minimum row height; aligns with project layout `applicationTopBarHeight`. */ -const APPLICATION_TOP_BAR_HEIGHT_PX = 48; +const APPLICATION_TOP_BAR_MIN_HEIGHT_PX = 48; export interface AppHeaderShellProps { - leading?: ReactNode; title?: ReactNode; badges?: ReactNode; titleActions?: ReactNode; @@ -35,20 +33,26 @@ const useHeaderStyles = () => { display: flex; flex-direction: column; min-width: 0; - min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; + min-height: ${APPLICATION_TOP_BAR_MIN_HEIGHT_PX}px; box-sizing: border-box; - padding: 0 ${euiTheme.size.s}; + padding: 0 ${euiTheme.size.m}; background: ${euiTheme.colors.backgroundBasePlain}; border-bottom: ${euiTheme.border.thin}; margin-bottom: -${euiTheme.border.width.thin}; + + &:hover .titleActionsReveal, + &:focus-within .titleActionsReveal { + opacity: 1; + pointer-events: auto; + } `; const primaryRow = css` display: flex; align-items: center; - gap: ${euiTheme.size.xs}; + gap: ${euiTheme.size.m}; min-width: 0; - min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; + min-height: ${APPLICATION_TOP_BAR_MIN_HEIGHT_PX}px; `; const titleCluster = css` @@ -88,12 +92,23 @@ const useHeaderStyles = () => { align-items: stretch; `; + const titleActionsReveal = css` + display: flex; + flex-shrink: 0; + align-items: center; + gap: ${euiTheme.size.xs}; + opacity: 0; + pointer-events: none; + transition: opacity ${euiTheme.animation.fast} ease; + `; + return { root, primaryRow, titleCluster, titleGroup, titleClusterSpacer, + titleActionsReveal, metadataRow, calloutRow, tabsRow, @@ -102,19 +117,22 @@ const useHeaderStyles = () => { }; export const AppHeaderShell = React.memo( - ({ leading, title, badges, titleActions, trailing, metadata, callout, tabs }) => { + ({ title, badges, titleActions, trailing, metadata, callout, tabs }) => { const styles = useHeaderStyles(); const heightRef = useReportTopBarHeight(); return (
- {leading}
{title} {badges} - {titleActions} + {titleActions && ( +
+ {titleActions} +
+ )}
diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx index e35199356d17d..4018abc28a029 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx @@ -7,12 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonIcon, useEuiTheme } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { useBackButton } from './hooks'; export const BackButton = React.memo(() => { + const { euiTheme } = useEuiTheme(); const back = useBackButton(); const ariaLabel = useMemo(() => { @@ -36,10 +37,11 @@ export const BackButton = React.memo(() => { return ( { + const { euiTheme } = useEuiTheme(); + const back = useBackButton(); + const title = useTitle(); + + const styles = useMemo(() => { + const wrapper = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.m}; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + `; + + const titleOffset = css` + padding-left: ${euiTheme.size.xs}; + `; + + return { wrapper, titleOffset }; + }, [euiTheme]); + + if (!title && !back) { + return null; + } + + return ( +
+ {back && } + {title && ( + +

{title}

+
+ )} +
+ ); +}); + +TitleArea.displayName = 'TitleArea'; From e410accc662b552947e071a693a17adfd45bac9d Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 15 Apr 2026 18:50:51 +0200 Subject: [PATCH 32/77] [FeatureBranch/DoNotReview] Chrome-next: header API partial-merge, back button popover, title wiring (#263533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Refactor `chrome.next.header` API: `set()` now shallow-merges (undefined values ignored), new `reset()` clears all or specific keys - Enhance back button with tooltip ("Back to ..."), popover menu for multiple targets, and extensible exclusion lists (deep link IDs + href patterns) - `back` config now accepts a single object or an array for explicit multi-target back navigation - Remove breadcrumb fallback from `useTitle` — apps must explicitly set title via the header API - Wire Discover to set title on mount/unmount, update Discover and Dashboard to use new `reset()` API - Exclude Stack Management root from back button breadcrumb fallback targets --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/chrome_next/app_header/app_header.tsx | 6 +- .../app_header/app_header_shell.tsx | 38 +++++-- .../chrome_next/app_header/back_button.tsx | 107 ++++++++++++++---- .../app_header/hooks/use_back_button.ts | 53 ++++++--- .../chrome_next/app_header/hooks/use_title.ts | 24 +--- .../src/chrome_next/app_header/title_area.tsx | 55 +++++++++ .../browser-internal/src/chrome_api.tsx | 1 + .../next_header/next_header_service.ts | 28 ++++- .../browser-mocks/src/chrome_service.mock.ts | 23 +++- .../browser/src/chrome_next/chrome_next.ts | 18 ++- .../chrome/browser/src/chrome_next/header.ts | 5 +- .../internal_dashboard_top_nav.tsx | 4 +- .../single_tab_view_with_app_menu.tsx | 2 +- .../components/tabs_view/hide_tabs_bar.tsx | 2 +- .../main/components/tabs_view/tabs_view.tsx | 2 +- .../application/main/discover_main_route.tsx | 7 ++ 16 files changed, 280 insertions(+), 95 deletions(-) create mode 100644 src/core/packages/chrome/browser-components/src/chrome_next/app_header/title_area.tsx diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx index dd21326aa47a5..d861ecf3b8d7f 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header.tsx @@ -10,15 +10,13 @@ import React from 'react'; import { AppHeaderShell } from './app_header_shell'; import { AppBadges } from './app_badges'; -import { BackButton } from './back_button'; -import { AppTitle } from './app_title'; +import { TitleArea } from './title_area'; import { GlobalActions } from './global_actions'; import { AppMenu } from './app_menu'; export const AppHeader = React.memo(() => ( } - title={} + title={} badges={} titleActions={} trailing={} diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx index 9136bb15813b2..df76c27b63b87 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/app_header_shell.tsx @@ -13,11 +13,9 @@ import { css } from '@emotion/react'; import React, { useMemo } from 'react'; import { useReportTopBarHeight } from './hooks'; -/** Minimum row height; aligns with project layout `applicationTopBarHeight`. */ -const APPLICATION_TOP_BAR_HEIGHT_PX = 48; +const APPLICATION_TOP_BAR_MIN_HEIGHT_PX = 48; export interface AppHeaderShellProps { - leading?: ReactNode; title?: ReactNode; badges?: ReactNode; titleActions?: ReactNode; @@ -35,20 +33,26 @@ const useHeaderStyles = () => { display: flex; flex-direction: column; min-width: 0; - min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; + min-height: ${APPLICATION_TOP_BAR_MIN_HEIGHT_PX}px; box-sizing: border-box; - padding: 0 ${euiTheme.size.s}; + padding: 0 ${euiTheme.size.m}; background: ${euiTheme.colors.backgroundBasePlain}; border-bottom: ${euiTheme.border.thin}; margin-bottom: -${euiTheme.border.width.thin}; + + &:hover .titleActionsReveal, + &:focus-within .titleActionsReveal { + opacity: 1; + pointer-events: auto; + } `; const primaryRow = css` display: flex; align-items: center; - gap: ${euiTheme.size.xs}; + gap: ${euiTheme.size.m}; min-width: 0; - min-height: ${APPLICATION_TOP_BAR_HEIGHT_PX}px; + min-height: ${APPLICATION_TOP_BAR_MIN_HEIGHT_PX}px; `; const titleCluster = css` @@ -88,12 +92,23 @@ const useHeaderStyles = () => { align-items: stretch; `; + const titleActionsReveal = css` + display: flex; + flex-shrink: 0; + align-items: center; + gap: ${euiTheme.size.xs}; + opacity: 0; + pointer-events: none; + transition: opacity ${euiTheme.animation.fast} ease; + `; + return { root, primaryRow, titleCluster, titleGroup, titleClusterSpacer, + titleActionsReveal, metadataRow, calloutRow, tabsRow, @@ -102,19 +117,22 @@ const useHeaderStyles = () => { }; export const AppHeaderShell = React.memo( - ({ leading, title, badges, titleActions, trailing, metadata, callout, tabs }) => { + ({ title, badges, titleActions, trailing, metadata, callout, tabs }) => { const styles = useHeaderStyles(); const heightRef = useReportTopBarHeight(); return (
- {leading}
{title} {badges} - {titleActions} + {titleActions && ( +
+ {titleActions} +
+ )}
diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx index e35199356d17d..34c97c7b5ff2a 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/back_button.tsx @@ -7,44 +7,101 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { EuiButtonIcon } from '@elastic/eui'; +import { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useBackButton } from './hooks'; +const backLabel = i18n.translate('core.ui.chrome.appHeader.backButtonAriaLabel', { + defaultMessage: 'Back', +}); + +const getBackToLabel = (destination: string) => + i18n.translate('core.ui.chrome.appHeader.backButtonAriaLabelWithDestination', { + defaultMessage: 'Back to {destination}', + values: { destination }, + }); + +const useBackButtonStyles = () => { + const { euiTheme } = useEuiTheme(); + + return useMemo(() => { + const button = css` + color: ${euiTheme.colors.textSubdued}; + `; + + return { button }; + }, [euiTheme]); +}; + export const BackButton = React.memo(() => { - const back = useBackButton(); - - const ariaLabel = useMemo(() => { - if (!back) { - return ''; - } - if (back.backDestinationLabel) { - return i18n.translate('core.ui.chrome.appHeader.backButtonAriaLabelWithDestination', { - defaultMessage: 'Back to {destination}', - values: { destination: back.backDestinationLabel }, - }); - } - return i18n.translate('core.ui.chrome.appHeader.backButtonAriaLabel', { - defaultMessage: 'Back', - }); - }, [back]); - - if (!back) { + const styles = useBackButtonStyles(); + const targets = useBackButton(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const togglePopover = useCallback(() => setIsPopoverOpen((open) => !open), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const primary = targets[0]; + const tooltip = primary?.backDestinationLabel + ? getBackToLabel(primary.backDestinationLabel) + : backLabel; + + if (!primary) { return null; } - return ( + const buttonIcon = ( 1 ? { onClick: togglePopover } : { href: primary.backHref })} /> ); + + if (targets.length > 1) { + return ( + + {buttonIcon} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + ( + + {target.backDestinationLabel ?? target.backHref} + + ))} + size="s" + /> + + ); + } + + return ( + + {buttonIcon} + + ); }); BackButton.displayName = 'BackButton'; diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_back_button.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_back_button.ts index d6cf302e97c52..3d359347aba53 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_back_button.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_back_button.ts @@ -8,46 +8,67 @@ */ import { useMemo } from 'react'; +import type { AppDeepLinkId, ChromeBreadcrumb } from '@kbn/core-chrome-browser'; import { useNextHeader, useProjectBreadcrumbs } from '../../../shared/chrome_hooks'; import { getBreadcrumbPlainText } from '../../../shared/breadcrumb_utils'; +const EXCLUDED_BACK_DEEP_LINKS = new Set([]); +const EXCLUDED_BACK_HREFS = [/\/app\/management\/?$/]; + +const isExcludedBackTarget = (crumb: ChromeBreadcrumb): boolean => { + if (crumb.deepLinkId && EXCLUDED_BACK_DEEP_LINKS.has(crumb.deepLinkId)) { + return true; + } + if (crumb.href) { + return EXCLUDED_BACK_HREFS.some((re) => re.test(crumb.href!)); + } + return false; +}; + export interface BackNavigation { backHref: string; - /** Plain-text title of the destination crumb (for `aria-label` on the back control). */ backDestinationLabel?: string; } +const EMPTY: BackNavigation[] = []; + /** - * Resolution: explicit `chrome.next.header` `back.href` (and optional `back.label`) -> else the - * last non-last project breadcrumb with a truthy `href` (scanning right to left). Returns - * `undefined` if neither applies. + * Returns all valid back-navigation targets derived from project breadcrumbs + * (scanning right-to-left, so the immediate parent is first). + * + * An explicit `chrome.next.header` `back.href` takes priority and produces a + * single-element array. */ -export function useBackButton(): BackNavigation | undefined { +export function useBackButton(): BackNavigation[] { const config = useNextHeader(); const breadcrumbs = useProjectBreadcrumbs(); return useMemo(() => { - const explicitHref = config?.back?.href?.trim(); - if (explicitHref) { - return { - backHref: explicitHref, - backDestinationLabel: config?.back?.label, - }; + if (config?.back) { + const backItems = Array.isArray(config.back) ? config.back : [config.back]; + const explicit = backItems + .filter((b) => b.href?.trim()) + .map((b) => ({ backHref: b.href, backDestinationLabel: b.label })); + if (explicit.length > 0) { + return explicit; + } } if (breadcrumbs.length < 2) { - return undefined; + return EMPTY; } + + const targets: BackNavigation[] = []; for (let i = breadcrumbs.length - 2; i >= 0; i--) { const crumb = breadcrumbs[i]; const href = crumb.href; - if (href) { - return { + if (href && !isExcludedBackTarget(crumb)) { + targets.push({ backHref: href, backDestinationLabel: getBreadcrumbPlainText(crumb), - }; + }); } } - return undefined; + return targets.length > 0 ? targets : EMPTY; }, [breadcrumbs, config]); } diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_title.ts b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_title.ts index 047ef3aa8b05f..dfade4e013a2e 100644 --- a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_title.ts +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/hooks/use_title.ts @@ -7,29 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useNextHeader, useProjectBreadcrumbs } from '../../../shared/chrome_hooks'; -import { getBreadcrumbPlainText } from '../../../shared/breadcrumb_utils'; +import { useNextHeader } from '../../../shared/chrome_hooks'; -/** - * Resolution: explicit `config.title` -> last project breadcrumb text -> `undefined`. - */ export function useTitle(): string | undefined { const config = useNextHeader(); - const breadcrumbs = useProjectBreadcrumbs(); - - if (config?.title) { - return config.title; - } - - if (breadcrumbs.length === 0) { - return undefined; - } - - const crumbForTitle = breadcrumbs[breadcrumbs.length - 1]; - const plain = getBreadcrumbPlainText(crumbForTitle); - if (plain) { - return plain; - } - - return undefined; + return config?.title; } diff --git a/src/core/packages/chrome/browser-components/src/chrome_next/app_header/title_area.tsx b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/title_area.tsx new file mode 100644 index 0000000000000..f23a9c69e402c --- /dev/null +++ b/src/core/packages/chrome/browser-components/src/chrome_next/app_header/title_area.tsx @@ -0,0 +1,55 @@ +/* + * 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 { EuiTitle, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useMemo } from 'react'; +import { useBackButton, useTitle } from './hooks'; +import { BackButton } from './back_button'; + +export const TitleArea = React.memo(() => { + const { euiTheme } = useEuiTheme(); + const backTargets = useBackButton(); + const hasBack = backTargets.length > 0; + const title = useTitle(); + + const styles = useMemo(() => { + const wrapper = css` + display: flex; + align-items: center; + gap: ${euiTheme.size.m}; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + `; + + const titleOffset = css` + padding-left: ${euiTheme.size.xs}; + `; + + return { wrapper, titleOffset }; + }, [euiTheme]); + + if (!title && !hasBack) { + return null; + } + + return ( +
+ {hasBack && } + {title && ( + +

{title}

+
+ )} +
+ ); +}); + +TitleArea.displayName = 'TitleArea'; diff --git a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx index 18bf68f647007..90a50e0d8db5e 100644 --- a/src/core/packages/chrome/browser-internal/src/chrome_api.tsx +++ b/src/core/packages/chrome/browser-internal/src/chrome_api.tsx @@ -176,6 +176,7 @@ export function createChromeApi({ state, services, sidebar }: ChromeApiDeps): In header: { get$: services.nextHeader.get$, set: services.nextHeader.set, + reset: services.nextHeader.reset, }, aiButton: { get$: () => state.aiButton.$.pipe(map((buttons) => [...buttons])), diff --git a/src/core/packages/chrome/browser-internal/src/services/next_header/next_header_service.ts b/src/core/packages/chrome/browser-internal/src/services/next_header/next_header_service.ts index 488502d43c280..a990334acb996 100644 --- a/src/core/packages/chrome/browser-internal/src/services/next_header/next_header_service.ts +++ b/src/core/packages/chrome/browser-internal/src/services/next_header/next_header_service.ts @@ -18,9 +18,31 @@ export class NextHeaderService { public start() { return { get$: () => this.config.$, - set: (value?: ChromeNextHeaderConfig) => this.config.set(value), - /** @internal Reset to initial state (e.g. on app change). */ - reset: () => this.config.set(undefined), + set: (partial: Partial) => { + const current = this.config.get() ?? {}; + const next = { ...current }; + for (const [key, value] of Object.entries(partial)) { + if (value !== undefined) { + (next as Record)[key] = value; + } + } + this.config.set( + Object.keys(next).length > 0 ? (next as ChromeNextHeaderConfig) : undefined + ); + }, + reset: (...keys: Array) => { + if (keys.length === 0) { + this.config.set(undefined); + return; + } + const current = this.config.get(); + if (!current) return; + const next = { ...current }; + for (const key of keys) { + delete next[key]; + } + this.config.set(Object.keys(next).length > 0 ? next : undefined); + }, }; } diff --git a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts index 020b039da4d08..2cf8b3f5cdda7 100644 --- a/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts +++ b/src/core/packages/chrome/browser-mocks/src/chrome_service.mock.ts @@ -117,8 +117,27 @@ const createStartContractMock = () => { next: lazyObject({ header: lazyObject({ get$: jest.fn().mockReturnValue(nextHeaderState$), - set: jest.fn((config?: ChromeNextHeaderConfig) => { - nextHeaderState$.next(config); + set: jest.fn((config: Partial) => { + const current = nextHeaderState$.getValue(); + const next = { ...current, ...config }; + nextHeaderState$.next( + Object.keys(next).length > 0 ? (next as ChromeNextHeaderConfig) : undefined + ); + }), + reset: jest.fn((...keys: Array) => { + if (keys.length === 0) { + nextHeaderState$.next(undefined); + return; + } + const current = nextHeaderState$.getValue(); + if (!current) return; + const next = { ...current }; + for (const key of keys) { + delete next[key]; + } + nextHeaderState$.next( + Object.keys(next).length > 0 ? (next as ChromeNextHeaderConfig) : undefined + ); }), }), aiButton: lazyObject({ diff --git a/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts index 7480157dca246..d402451bc5cdc 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/chrome_next.ts @@ -20,13 +20,19 @@ import type { ChromeNextSpaceSelectorConfig } from './space_selector'; export interface ChromeNext { header: { /** - * Set the Chrome-Next header configuration for the current page. - * Chrome renders the title, metadata, global actions, and app menu. - * - * Pass `undefined` to clear (e.g. on unmount or route change). - * Automatically cleared on app change. + * Shallow-merge fields into the current Chrome-Next header configuration. + * Only provided keys are updated; `undefined` values in the partial are ignored. + * Use {@link reset} to clear fields. */ - set(config?: ChromeNextHeaderConfig): void; + set(config: Partial): void; + + /** + * Clear Chrome-Next header configuration. + * Called with no arguments: clears everything. + * Called with key names: clears only those fields. + * Automatically called with no arguments on app change. + */ + reset(...keys: Array): void; }; aiButton: { /** diff --git a/src/core/packages/chrome/browser/src/chrome_next/header.ts b/src/core/packages/chrome/browser/src/chrome_next/header.ts index 3171a72164842..befb6fd40b972 100644 --- a/src/core/packages/chrome/browser/src/chrome_next/header.ts +++ b/src/core/packages/chrome/browser/src/chrome_next/header.ts @@ -61,9 +61,10 @@ export interface ChromeNextHeaderConfig { /** * Optional explicit back navigation for the Chrome-Next header back control. - * When `href` is set, overrides breadcrumb-derived back destination. + * When set, overrides breadcrumb-derived back destinations. + * A single object produces a direct link; an array produces a popover menu. */ - back?: ChromeNextHeaderBack; + back?: ChromeNextHeaderBack | ChromeNextHeaderBack[]; } /** Explicit back target for {@link ChromeNextHeaderConfig.back}. */ diff --git a/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx b/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index 439626b243a71..aa3ad3dec692c 100644 --- a/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx +++ b/src/platform/plugins/shared/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -323,10 +323,10 @@ export function InternalDashboardTopNav({ coreServices.chrome.next.header.set({ title: title ?? '', - globalActions: Object.keys(globalActions).length > 0 ? globalActions : undefined, + ...(Object.keys(globalActions).length > 0 ? { globalActions } : {}), }); return () => { - coreServices.chrome.next.header.set(undefined); + coreServices.chrome.next.header.reset(); }; }, [title, chromeNextHeaderShareGlobalAction, chromeNextHeaderFavoriteGlobalAction]); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx index 8846dd758ce05..f2b19d664096e 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/single_tab_view/single_tab_view_with_app_menu.tsx @@ -22,7 +22,7 @@ export const SingleTabViewWithAppMenu = (props: SingleTabViewProps) => { if (isNextChrome && topNavMenuItems) { chrome.next.header.set({ appMenu: topNavMenuItems }); return () => { - chrome.next.header.set(undefined); + chrome.next.header.reset('appMenu'); }; } }, [isNextChrome, topNavMenuItems, chrome.next.header]); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx index 0bae1e40c41a0..7f77f7208f78a 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/hide_tabs_bar.tsx @@ -39,7 +39,7 @@ export const HideTabsBar: FC<{ if (isNextChrome && customizationContext.displayMode === 'standalone' && topNavMenuItems) { chrome.next.header.set({ appMenu: topNavMenuItems }); return () => { - chrome.next.header.set(undefined); + chrome.next.header.reset('appMenu'); }; } }, [isNextChrome, customizationContext.displayMode, topNavMenuItems, chrome.next.header]); diff --git a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx index b944bf68e72f1..fba76539a3052 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/tabs_view/tabs_view.tsx @@ -53,7 +53,7 @@ export const TabsView = (props: SingleTabViewProps) => { if (isNextChrome && topNavMenuItems) { services.chrome.next.header.set({ appMenu: topNavMenuItems }); return () => { - services.chrome.next.header.set(undefined); + services.chrome.next.header.reset('appMenu'); }; } }, [isNextChrome, topNavMenuItems, services.chrome.next.header]); diff --git a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx index db2fb4daaa52b..bdd71a2c44c7d 100644 --- a/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/discover_main_route.tsx @@ -220,9 +220,16 @@ const DiscoverMainRouteContent = (props: SingleTabViewProps) => { titleBreadcrumbText: persistedDiscoverSession?.title, services, }); + chrome.next.header.set({ + title: persistedDiscoverSession?.title || 'Discover', + }); } + return () => { + chrome.next.header.reset('title'); + }; }, [ chrome.docTitle, + chrome.next.header, persistedDiscoverSession?.title, customizationContext.displayMode, services, From 21227e3b905077cf1849844db361504dca1d1f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngeles=20Mart=C3=ADnez=20Barrio?= Date: Thu, 16 Apr 2026 10:00:32 +0200 Subject: [PATCH 33/77] [FeatureBranch/DoNotReview][Chrome Next] Add context switcher UI package (#263516) Relates to https://github.com/elastic/kibana/issues/259992 ## Summary - This PR introduces a new shared UI package for the context switcher that will be rendered in the Kibana global header ## Testing - Run `node scripts/storybook context-switcher-components` and verify all three stories render correctly --- .github/CODEOWNERS | 1 + package.json | 1 + src/dev/storybook/aliases.ts | 2 + .../.storybook/main.js | 10 + .../context-switcher-components/README.md | 3 + .../context-switcher-components/index.ts | 18 ++ .../jest.config.js | 14 + .../context-switcher-components/kibana.jsonc | 7 + .../context-switcher-components/package.json | 7 + .../src/components/context_row.tsx | 78 ++++++ .../components/context_switcher.stories.tsx | 219 ++++++++++++++++ .../src/components/context_switcher.tsx | 240 ++++++++++++++++++ .../context_switcher_trigger_button.tsx | 64 +++++ .../src/components/footer.tsx | 39 +++ .../src/components/links_list.tsx | 51 ++++ .../src/components/selectable_list.tsx | 185 ++++++++++++++ .../src/components/types.ts | 98 +++++++ .../src/components/views/context_menu.tsx | 52 ++++ .../src/components/views/context_submenu.tsx | 83 ++++++ .../src/components/views/spaces_list.tsx | 149 +++++++++++ .../context-switcher-components/tsconfig.json | 16 ++ tsconfig.base.json | 2 + yarn.lock | 4 + 23 files changed, 1343 insertions(+) create mode 100644 src/platform/packages/shared/context-switcher-components/.storybook/main.js create mode 100644 src/platform/packages/shared/context-switcher-components/README.md create mode 100644 src/platform/packages/shared/context-switcher-components/index.ts create mode 100644 src/platform/packages/shared/context-switcher-components/jest.config.js create mode 100644 src/platform/packages/shared/context-switcher-components/kibana.jsonc create mode 100644 src/platform/packages/shared/context-switcher-components/package.json create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/context_row.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/context_switcher.stories.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/context_switcher.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/context_switcher_trigger_button.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/footer.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/links_list.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/selectable_list.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/types.ts create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/views/context_menu.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/views/context_submenu.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/src/components/views/spaces_list.tsx create mode 100644 src/platform/packages/shared/context-switcher-components/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 193af296d4256..3aeb9b9557d81 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -452,6 +452,7 @@ src/platform/packages/shared/content-management/table_list_view @elastic/appex-s src/platform/packages/shared/content-management/table_list_view_common @elastic/appex-sharedux src/platform/packages/shared/content-management/table_list_view_table @elastic/appex-sharedux src/platform/packages/shared/content-management/user_profiles @elastic/appex-sharedux +src/platform/packages/shared/context-switcher-components @elastic/appex-sharedux src/platform/packages/shared/controls/control-group-renderer @elastic/kibana-presentation src/platform/packages/shared/controls/controls-constants @elastic/kibana-presentation src/platform/packages/shared/controls/controls-schemas @elastic/kibana-presentation diff --git a/package.json b/package.json index e5eac78bdaa5f..c56c7fc732bcf 100644 --- a/package.json +++ b/package.json @@ -318,6 +318,7 @@ "@kbn/content-management-user-profiles": "link:src/platform/packages/shared/content-management/user_profiles", "@kbn/content-management-utils": "link:src/platform/packages/shared/kbn-content-management-utils", "@kbn/content-packs-schema": "link:x-pack/platform/packages/shared/kbn-content-packs-schema", + "@kbn/context-switcher-components": "link:src/platform/packages/shared/context-switcher-components", "@kbn/control-group-renderer": "link:src/platform/packages/shared/controls/control-group-renderer", "@kbn/controls-constants": "link:src/platform/packages/shared/controls/controls-constants", "@kbn/controls-example-plugin": "link:examples/controls_example", diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 0b5e8b6a78904..1bddc349557ce 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -24,6 +24,8 @@ export const storybookAliases = { content_management: 'src/platform/packages/shared/content-management/kbn-content-management-storybook', content_management_examples: 'examples/content_management_examples/.storybook', + context_switcher_components: + 'src/platform/packages/shared/context-switcher-components/.storybook', classic_stream_flyout: 'x-pack/platform/packages/shared/kbn-classic-stream-flyout/.storybook', custom_icons: 'src/platform/packages/shared/kbn-custom-icons/.storybook', custom_integrations: 'src/platform/plugins/shared/custom_integrations/storybook', diff --git a/src/platform/packages/shared/context-switcher-components/.storybook/main.js b/src/platform/packages/shared/context-switcher-components/.storybook/main.js new file mode 100644 index 0000000000000..4c71be3362b05 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * 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". + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/platform/packages/shared/context-switcher-components/README.md b/src/platform/packages/shared/context-switcher-components/README.md new file mode 100644 index 0000000000000..0acbf137cbdd5 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/README.md @@ -0,0 +1,3 @@ +# @kbn/context-switcher-components + +Context switcher components. diff --git a/src/platform/packages/shared/context-switcher-components/index.ts b/src/platform/packages/shared/context-switcher-components/index.ts new file mode 100644 index 0000000000000..d10ce06ed150e --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/index.ts @@ -0,0 +1,18 @@ +/* + * 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". + */ + +export { ContextSwitcher } from './src/components/context_switcher'; +export type { + ContextSwitcherProps, + ContextSwitcherSpacesConfig, + ContextSwitcherEnvironmentConfig, + SpaceItem, + LinksListItem, + FooterAction, +} from './src/components/types'; diff --git a/src/platform/packages/shared/context-switcher-components/jest.config.js b/src/platform/packages/shared/context-switcher-components/jest.config.js new file mode 100644 index 0000000000000..db103b4bb8f1f --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/jest.config.js @@ -0,0 +1,14 @@ +/* + * 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". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/platform/packages/shared/context-switcher-components'], +}; diff --git a/src/platform/packages/shared/context-switcher-components/kibana.jsonc b/src/platform/packages/shared/context-switcher-components/kibana.jsonc new file mode 100644 index 0000000000000..03e25d465a87f --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/context-switcher-components", + "owner": "@elastic/appex-sharedux", + "group": "platform", + "visibility": "shared" +} diff --git a/src/platform/packages/shared/context-switcher-components/package.json b/src/platform/packages/shared/context-switcher-components/package.json new file mode 100644 index 0000000000000..02af0cca8ffd2 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/context-switcher-components", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0", + "sideEffects": false +} \ No newline at end of file diff --git a/src/platform/packages/shared/context-switcher-components/src/components/context_row.tsx b/src/platform/packages/shared/context-switcher-components/src/components/context_row.tsx new file mode 100644 index 0000000000000..99ff378d2dcf8 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/context_row.tsx @@ -0,0 +1,78 @@ +/* + * 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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiListGroupItem, EuiText } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { CONTEXT_ROW_HEIGHT, type ContextRowModel } from './types'; + +export interface ContextRowProps { + readonly row: ContextRowModel; + readonly onClick: () => void; +} + +/** + * The row component for the context switcher that contains the avatar, the title, the subtitle and the chevron. + */ + +export const ContextRow = ({ row, onClick }: ContextRowProps) => { + const rowStyles = css` + && .euiListGroupItem__label { + flex: 1 1 auto; + min-width: 0; + } + && .euiListGroupItem__button { + min-height: ${CONTEXT_ROW_HEIGHT}px; + } + /* no underline */ + && .euiListGroupItem__button:hover, + && .euiListGroupItem__button:focus { + text-decoration: none; + } + `; + + const truncateCss = css` + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + + const rowLabel = ( + + + + {row.label} + + {row.value != null && ( + + {row.value} + + )} + + + + + + ); + + return ( + + ); +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/context_switcher.stories.tsx b/src/platform/packages/shared/context-switcher-components/src/components/context_switcher.stories.tsx new file mode 100644 index 0000000000000..771a808a8d511 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/context_switcher.stories.tsx @@ -0,0 +1,219 @@ +/* + * 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 React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { EuiAvatar, EuiBadge, EuiButtonEmpty, EuiPanel } from '@elastic/eui'; + +import { ContextSwitcher } from './context_switcher'; +import type { SpaceItem, LinksListItem } from './types'; + +interface StoryArgs { + searchThreshold: number; +} + +const meta: Meta = { + title: 'Context switcher', + parameters: { layout: 'centered' }, + argTypes: { + searchThreshold: { + control: { type: 'number', min: 1, max: 15 }, + description: 'Number of spaces required to show the search box', + }, + }, + args: { + searchThreshold: 15, + }, +}; + +export default meta; +type Story = StoryObj; + +const MOCK_SPACES: Array = [ + { id: 'default', name: 'Default', solution: 'Security', solutionIcon: 'logoSecurity' }, + { + id: 'obs', + name: 'My awesome space', + solution: 'Observability', + solutionIcon: 'logoObservability', + }, + { + id: 'search', + name: 'One more space', + solution: 'Elasticsearch', + solutionIcon: 'logoElasticsearch', + }, + { id: 'sec', name: 'Another awesome space', solution: 'Security', solutionIcon: 'logoSecurity' }, + { + id: 'data', + name: 'Data Insight Realm', + solution: 'Elasticsearch', + solutionIcon: 'logoElasticsearch', + }, + { + id: 'k', + name: 'Knowledge Discovery', + solution: 'Observability', + solutionIcon: 'logoObservability', + }, +]; + +const MOCK_FOOTER_LINKS: LinksListItem[] = [ + { + id: 'connection-details', + label: 'Connection details', + onClick: () => action('connection-details')(), + iconType: 'plugs', + }, + { + id: 'manage-deployments', + label: 'Manage deployments', + onClick: () => action('manage-deployments')(), + iconType: 'gear', + }, + { + id: 'invite-users', + label: 'Invite users', + href: 'https://example.com', + iconType: 'user', + external: true, + }, +]; + +const buildSpaceItems = (showBadges: boolean): SpaceItem[] => + MOCK_SPACES.map((s) => ({ + ...s, + avatar: , + badge: showBadges ? ( + + {s.solution} + + ) : undefined, + })); + +const buildManageAction = () => ( + + Manage + +); + +const useSpacesConfig = (showBadges: boolean, searchThreshold?: number) => { + const [activeSpaceId, setActiveSpaceId] = useState('obs'); + const activeSpace = MOCK_SPACES.find((s) => s.id === activeSpaceId)!; + return { + active: activeSpace, + items: buildSpaceItems(showBadges), + onSelect: (spaceId: string) => { + action('select-space')(spaceId); + setActiveSpaceId(spaceId); + }, + headerAction: buildManageAction(), + footerAction: SPACES_FOOTER_ACTION, + search: searchThreshold != null ? { threshold: searchThreshold } : undefined, + }; +}; + +const SPACES_FOOTER_ACTION = { + id: 'create-space', + label: 'Create space', + onClick: () => action('create-space')(), +}; + +const CloudScenario = ({ searchThreshold }: { searchThreshold: number }) => { + const spaces = useSpacesConfig(true, searchThreshold); + + return ( + + action('manage-discovery-insights')(), + }, + { + id: 'view-all-deployments', + label: 'View all deployments', + iconType: 'grid', + onClick: () => action('view-all-deployments')(), + }, + ], + submenuFooterAction: { + id: 'create-deployment', + label: 'Create deployment', + onClick: () => action('create-deployment')(), + }, + }} + footerLinks={MOCK_FOOTER_LINKS} + /> + + ); +}; + +const ServerlessScenario = ({ searchThreshold }: { searchThreshold: number }) => { + const spaces = useSpacesConfig(false, searchThreshold); + + return ( + + action('manage-insightful-analytics')(), + }, + { + id: 'view-all-projects', + label: 'View all projects', + iconType: 'grid', + onClick: () => action('view-all-projects')(), + }, + ], + submenuFooterAction: { + id: 'create-project', + label: 'Create project', + onClick: () => action('create-project')(), + }, + }} + footerLinks={MOCK_FOOTER_LINKS} + /> + + ); +}; + +const SelfHostedScenario = ({ searchThreshold }: { searchThreshold: number }) => { + const spaces = useSpacesConfig(true, searchThreshold); + + return ( + + + + ); +}; + +export const CloudMode: Story = { + render: (args) => , +}; +export const ServerlessMode: Story = { + render: (args) => , +}; +export const SelfHostedMode: Story = { + render: (args) => , +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/context_switcher.tsx b/src/platform/packages/shared/context-switcher-components/src/components/context_switcher.tsx new file mode 100644 index 0000000000000..c80386222ce0c --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/context_switcher.tsx @@ -0,0 +1,240 @@ +/* + * 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 type { ReactElement } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SpacesListView } from './views/spaces_list'; +import type { SpacesListViewProps } from './views/spaces_list'; +import { ContextMenuView } from './views/context_menu'; +import type { ContextMenuViewProps } from './views/context_menu'; +import { ContextSubmenuView } from './views/context_submenu'; +import type { ContextSubmenuViewProps } from './views/context_submenu'; +import { ContextSwitcherTriggerButton } from './context_switcher_trigger_button'; +import { POPOVER_WIDTH, type ContextSwitcherProps, type SpaceItem } from './types'; +import type { SelectableListItem } from './selectable_list'; + +const SEARCH_THRESHOLD = 15; + +const SUBMENU_TITLES: Record<'project' | 'deployment', string> = { + project: i18n.translate('contextSwitcherComponents.submenuTitle.projects', { + defaultMessage: 'My projects', + }), + deployment: i18n.translate('contextSwitcherComponents.submenuTitle.deployments', { + defaultMessage: 'My deployments', + }), +}; + +const SPACES_TITLE = i18n.translate('contextSwitcherComponents.spacesTitle', { + defaultMessage: 'Spaces', +}); + +const CONTEXT_SWITCHER_ARIA_LABEL = i18n.translate('contextSwitcherComponents.popover.ariaLabel', { + defaultMessage: 'Context switcher', +}); + +const mapSpaceToSelectableItem = (space: SpaceItem, activeId: string): SelectableListItem => ({ + id: space.id, + label: space.name, + checked: space.id === activeId, + prepend: space.avatar ?? , + append: space.badge, + 'data-test-subj': `space-${space.id}`, +}); + +type PopoverViewId = 'root' | 'environment' | 'spaces'; + +interface TwoStepContentProps { + readonly rootView: Omit; + readonly environmentSubmenuView: Omit; + readonly spacesSubmenuView: Omit; +} + +const TwoStepContent = ({ + rootView, + environmentSubmenuView, + spacesSubmenuView, +}: TwoStepContentProps) => { + const [view, setView] = useState('root'); + + if (view === 'environment') { + return setView('root')} />; + } + if (view === 'spaces') { + return setView('root')} />; + } + return ( + setView('environment')} + onClickSpacesRow={() => setView('spaces')} + /> + ); +}; + +/** + * The context switcher component. + * It shows a trigger button and a popover. + * The popover can be a single step or a two step content. + * The single step content is the spaces list. + * The two step content is the context menu and the spaces list. + */ +export const ContextSwitcher = ({ + spaces, + environmentContext, + footerLinks, +}: ContextSwitcherProps) => { + const { euiTheme } = useEuiTheme(); + + const [isOpen, setIsOpen] = useState(false); + const togglePopover = useCallback(() => setIsOpen((prev) => !prev), []); + const closePopover = useCallback(() => setIsOpen(false), []); + + const triggerButtonIcon = spaces.active.solutionIcon ?? 'logoElastic'; + + const selectableItems = useMemo( + () => spaces.items.map((space) => mapSpaceToSelectableItem(space, spaces.active.id)), + [spaces.items, spaces.active.id] + ); + + const handleSpaceSelect = useCallback( + ({ item }) => { + spaces.onSelect(item.id); + closePopover(); + }, + [spaces, closePopover] + ); + + const searchConfig = + selectableItems.length >= (spaces.search?.threshold ?? SEARCH_THRESHOLD) + ? { + enabled: true, + props: { + placeholder: + spaces.search?.placeholder ?? + i18n.translate('contextSwitcherComponents.searchPlaceholder', { + defaultMessage: 'Find a space', + }), + compressed: true, + isClearable: true, + }, + } + : undefined; + + const spacesViewProps = { + id: 'contextSwitcherSpacesList', + title: SPACES_TITLE, + headerAction: spaces.headerAction, + items: selectableItems, + onSelect: handleSpaceSelect, + search: searchConfig, + isLoading: spaces.isLoading, + footerAction: spaces.footerAction, + }; + + const environmentDescription = + environmentContext && spaces.active.solution + ? i18n.translate('contextSwitcherComponents.environmentContext.description', { + defaultMessage: '{solution} {kind}', + values: { + solution: spaces.active.solution, + kind: environmentContext.environmentType, + }, + }) + : undefined; + + const environmentIcon: ReactElement | undefined = + environmentContext?.environmentType === 'project' && spaces.active.solutionIcon ? ( + + ) : undefined; + + const spacesDescription = + spaces.active.solution && environmentContext?.environmentType === 'deployment' ? ( + + {spaces.active.solutionIcon && ( + + + + )} + + {i18n.translate('contextSwitcherComponents.spacesRow.description', { + defaultMessage: '{solution} space', + values: { solution: spaces.active.solution }, + })} + + + ) : undefined; + + return ( + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="downLeft" + panelStyle={{ width: POPOVER_WIDTH }} + panelPaddingSize="s" + ownFocus + repositionOnScroll + data-test-subj="contextSwitcherPopover" + > + {!environmentContext ? ( + + ) : ( + + ), + value: spacesDescription, + }, + footerLinks, + }} + environmentSubmenuView={{ + title: SUBMENU_TITLES[environmentContext.environmentType], + items: environmentContext.submenuItems, + footerAction: environmentContext.submenuFooterAction, + }} + spacesSubmenuView={spacesViewProps} + /> + )} + + ); +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/context_switcher_trigger_button.tsx b/src/platform/packages/shared/context-switcher-components/src/components/context_switcher_trigger_button.tsx new file mode 100644 index 0000000000000..2c8830ca62c89 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/context_switcher_trigger_button.tsx @@ -0,0 +1,64 @@ +/* + * 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 React from 'react'; +import type { ReactElement } from 'react'; +import type { IconType } from '@elastic/eui'; +import { EuiButtonEmpty, EuiIcon, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; + +const CONTEXT_SWITCHER_BUTTON_ARIA_LABEL = i18n.translate( + 'contextSwitcherComponents.triggerButton.ariaLabel', + { + defaultMessage: 'Open context switcher', + } +); + +interface ContextSwitcherTriggerButtonProps { + readonly solutionIcon: IconType; + readonly spaceName: string; + readonly onClick: () => void; + readonly isSelected?: boolean; +} + +/** + * Trigger button UI for the context switcher popover. + * Solution logo (left), space name (middle), down arrow (right). + */ +export const ContextSwitcherTriggerButton = ({ + solutionIcon, + spaceName, + onClick, + isSelected, +}: ContextSwitcherTriggerButtonProps): ReactElement => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + {spaceName} + + + ); +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/footer.tsx b/src/platform/packages/shared/context-switcher-components/src/components/footer.tsx new file mode 100644 index 0000000000000..ee7fd2ff280f9 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/footer.tsx @@ -0,0 +1,39 @@ +/* + * 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 React from 'react'; +import { EuiListGroup, EuiListGroupItem } from '@elastic/eui'; +import type { FooterAction } from './types'; + +export interface FooterProps { + readonly action?: FooterAction; +} + +/** + * Renders a single footer action row. + */ +export const Footer = ({ action }: FooterProps) => { + if (!action) return null; + + return ( + + + + ); +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/links_list.tsx b/src/platform/packages/shared/context-switcher-components/src/components/links_list.tsx new file mode 100644 index 0000000000000..66be2b6b93689 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/links_list.tsx @@ -0,0 +1,51 @@ +/* + * 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 React from 'react'; +import { EuiListGroup, EuiListGroupItem } from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { LinksListItem } from './types'; + +export interface LinksListProps { + readonly items: ReadonlyArray; +} + +/** + * Renders a list of links. + */ + +export const LinksList = ({ items }: LinksListProps) => { + const itemCss = css` + /* no underline */ + && .euiListGroupItem__button:hover, + && .euiListGroupItem__button:focus { + text-decoration: none; + } + `; + + return ( + + {items.map((item) => ( + + ))} + + ); +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/selectable_list.tsx b/src/platform/packages/shared/context-switcher-components/src/components/selectable_list.tsx new file mode 100644 index 0000000000000..578a263fc1a15 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/selectable_list.tsx @@ -0,0 +1,185 @@ +/* + * 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 React, { useCallback, useMemo } from 'react'; +import type { ComponentProps, ReactElement, ReactNode } from 'react'; +import { EuiSelectable, useEuiTheme } from '@elastic/eui'; +import type { EuiSelectableOption } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import classNames from 'classnames'; +import { SELECTABLE_ROW_HEIGHT } from './types'; + +const SELECTABLE_LIST_CHECKED_ITEM_CLASS = 'kbnContextSwitcherSelectableListItem--checked'; + +type EuiSelectableChangeHandler = NonNullable['onChange']>; +type EuiSelectableChangeEvent = Parameters[1]; + +export interface SelectableListSearchConfig { + readonly enabled: boolean; + readonly props?: ComponentProps['searchProps']; +} + +export interface SelectableListItem { + readonly id: string; + readonly label: string; + readonly prepend?: ReactNode; + readonly append?: ReactNode; + readonly checked?: boolean; + readonly disabled?: boolean; + readonly className?: string; + readonly ['data-test-subj']?: string; +} + +export interface SelectableListProps { + readonly id: string; + readonly items: ReadonlyArray; + readonly isLoading?: boolean; + readonly loadingMessage?: string; + readonly noMatchesMessage?: ReactElement; + readonly search?: SelectableListSearchConfig; + readonly onSelect: (args: { + readonly item: SelectableListItem; + readonly event: EuiSelectableChangeEvent; + readonly previousSelectedId?: string; + }) => void; + readonly children?: (nodes: { + readonly list: ReactNode; + readonly search?: ReactNode; + }) => ReactNode; +} + +/** + * Generic selectable list for context-switcher: + * - Single selection (always) + * - Optional search UI + * - Supports prepend/append per item + */ +export const SelectableList = ({ + id, + items, + isLoading = false, + loadingMessage, + noMatchesMessage, + search, + onSelect, + children, +}: SelectableListProps) => { + const { euiTheme } = useEuiTheme(); + + const previousSelectedId = useMemo(() => items.find((i) => i.checked)?.id, [items]); + + const itemById = useMemo(() => { + const map = new Map(); + for (const item of items) map.set(item.id, item); + return map; + }, [items]); + + const options: Array = useMemo( + () => + items.map((item) => ({ + key: item.id, + label: item.label, + prepend: item.prepend, + append: item.append, + checked: item.checked ? ('on' as const) : undefined, + disabled: item.disabled, + className: classNames(item.className, item.checked && SELECTABLE_LIST_CHECKED_ITEM_CLASS), + 'data-test-subj': item['data-test-subj'], + })), + [items] + ); + + const defaultNoMatchesMessage = ( + + ); + + const handleChange = useCallback( + (newOptions, event) => { + const selected = newOptions.find((o) => o.checked === 'on'); + const selectedId = selected?.key != null ? String(selected.key) : undefined; + if (!selectedId) return; + + const item = itemById.get(selectedId); + if (!item) return; + + onSelect({ item, event, previousSelectedId }); + }, + [itemById, onSelect, previousSelectedId] + ); + + const selectableStyles = css` + /* no underline */ + .euiSelectableListItem:hover .euiSelectableListItem__text, + .euiSelectableListItem.euiSelectableListItem-isFocused .euiSelectableListItem__text { + text-decoration: none; + } + /* blue background color and rounded corners */ + .euiSelectableListItem { + &:hover:not([aria-disabled='true']), + &.euiSelectableListItem-isFocused:not([aria-disabled='true']), + &.${SELECTABLE_LIST_CHECKED_ITEM_CLASS} { + background-color: ${euiTheme.colors.backgroundBaseInteractiveSelect}; + border-radius: ${euiTheme.border.radius.small}; + } + } + /* blue icon color */ + .euiSelectableListItem__icon { + color: ${euiTheme.colors.textPrimary}; + } + /* badges: no background + no border */ + .euiSelectableListItem__append .euiBadge:not(.euiSelectableListItem__onFocusBadge) { + --euiBadgeTextColor: ${euiTheme.colors.textSubdued}; + background-color: transparent; + border: 0; + box-shadow: none; + } + /* no bottom border between items */ + .euiSelectableListItem:not(:last-of-type) { + border-bottom: 0; + } + `; + + const searchableProps = search?.enabled + ? { searchable: true as const, searchProps: search.props } + : { searchable: false as const }; + + return ( + + {(list, searchNode) => { + if (children) return <>{children({ list, search: searchNode ?? undefined })}; + + return ( + <> + {searchNode} + {list} + + ); + }} + + ); +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/types.ts b/src/platform/packages/shared/context-switcher-components/src/components/types.ts new file mode 100644 index 0000000000000..9f15a3492cee9 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/types.ts @@ -0,0 +1,98 @@ +/* + * 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 type { ReactElement, ReactNode } from 'react'; +import type { IconType } from '@elastic/eui'; + +export const POPOVER_WIDTH = 400; +export const SELECTABLE_ROW_HEIGHT = 40; +export const CONTEXT_ROW_HEIGHT = 48; + +export interface SpaceItem { + id: string; + name: string; + /** Avatar or icon to display. */ + avatar?: ReactElement; + /** Optional solution badge or metadata shown alongside. */ + badge?: ReactNode; + /** Solution name (e.g. "Security", "Observability"). Used to derive labels and icons. */ + solution?: string; + /** Solution icon type (e.g. "logoSecurity"). Used for trigger button and environment row avatar. */ + solutionIcon?: IconType; +} + +export interface ContextSwitcherSpacesConfig { + /** The currently active space. */ + active: SpaceItem; + /** All available spaces (including the active one). */ + items: SpaceItem[]; + /** Called when user selects a space. */ + onSelect: (spaceId: string) => void; + /** Optional search config. */ + search?: { placeholder?: string; threshold?: number }; + /** Header action (e.g. "Manage" button). */ + headerAction?: ReactNode; + /** Footer action (e.g. "Create space"). */ + footerAction?: FooterAction; + isLoading?: boolean; +} + +export interface ContextSwitcherEnvironmentConfig { + /** Determines static labels (e.g. "My projects" vs "My deployments"). */ + environmentType: 'project' | 'deployment'; + name: string; + /** Submenu link items (e.g. "Manage project", "View all deployments"). */ + submenuItems: LinksListItem[]; + /** Submenu footer action (e.g. "Create project"). */ + submenuFooterAction?: FooterAction; +} + +export interface ContextSwitcherProps { + /** Active space info + full list of spaces. */ + spaces: ContextSwitcherSpacesConfig; + /** + * If provided, enables the root menu with an environment row + * (project or deployment) + submenu navigation. + * When absent, the popover shows only the spaces list. + */ + environmentContext?: ContextSwitcherEnvironmentConfig; + /** Optional footer links (e.g. "Connection details", "Manage deployments"). */ + footerLinks?: LinksListItem[]; +} + +export interface LinksListItem { + id: string; + label: ReactNode; + href?: string; + onClick?: () => void; + external?: boolean; + disabled?: boolean; + iconType?: IconType; + ['data-test-subj']?: string; +} + +export interface FooterAction { + id: string; + label: string; + href?: string; + onClick?: () => void; + external?: boolean; + disabled?: boolean; + ['data-test-subj']?: string; +} + +export interface ContextRowModel { + id: string; + label: ReactNode; + value?: ReactNode; + prepend?: ReactElement; + disabled?: boolean; + ariaLabel?: string; + ['data-test-subj']?: string; +} diff --git a/src/platform/packages/shared/context-switcher-components/src/components/views/context_menu.tsx b/src/platform/packages/shared/context-switcher-components/src/components/views/context_menu.tsx new file mode 100644 index 0000000000000..c8b577b8cc2f4 --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/views/context_menu.tsx @@ -0,0 +1,52 @@ +/* + * 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 React from 'react'; +import { EuiHorizontalRule, EuiListGroup } from '@elastic/eui'; + +import { ContextRow } from '../context_row'; +import { LinksList } from '../links_list'; +import type { ContextRowModel } from '../types'; +import { type LinksListItem } from '../types'; + +export interface ContextMenuViewProps { + readonly environmentRow: ContextRowModel; + readonly spacesRow: ContextRowModel; + readonly footerLinks?: ReadonlyArray; + readonly onClickEnvironmentRow: () => void; + readonly onClickSpacesRow: () => void; +} + +/** + * The menu view for the context switcher that contains the environment row and the spaces row. + */ + +export const ContextMenuView = ({ + environmentRow, + spacesRow, + onClickEnvironmentRow, + onClickSpacesRow, + footerLinks, +}: ContextMenuViewProps) => { + return ( + <> + + + + + + {!!footerLinks?.length && ( + <> + + + + )} + + ); +}; diff --git a/src/platform/packages/shared/context-switcher-components/src/components/views/context_submenu.tsx b/src/platform/packages/shared/context-switcher-components/src/components/views/context_submenu.tsx new file mode 100644 index 0000000000000..0a1bef26385fb --- /dev/null +++ b/src/platform/packages/shared/context-switcher-components/src/components/views/context_submenu.tsx @@ -0,0 +1,83 @@ +/* + * 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 React from 'react'; +import type { ReactNode } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPopoverTitle, +} from '@elastic/eui'; +import { css } from '@emotion/react'; + +import { i18n } from '@kbn/i18n'; +import { LinksList } from '../links_list'; +import { Footer } from '../footer'; +import { type FooterAction, type LinksListItem } from '../types'; + +const BACK_BUTTON_ARIA_LABEL = i18n.translate( + 'contextSwitcherComponents.contextSubmenuView.backButtonAriaLabel', + { + defaultMessage: 'Back', + } +); +export interface ContextSubmenuViewProps { + readonly title: ReactNode; + readonly onBack: () => void; + readonly items: ReadonlyArray; + readonly footerAction?: FooterAction; +} + +/** + * The submenu view for the environment context that contains the title, the links list and the footer action. + */ + +export const ContextSubmenuView = ({ + title, + onBack, + items, + footerAction, +}: ContextSubmenuViewProps) => { + return ( + <> + + + + + + {title} + + + + + + {footerAction && ( + <> + +