Skip to content

Commit a34e41c

Browse files
[Chrome Next] App Header (#271288)
## Summary Part of elastic/kibana-team#3344 Extracts the app header infrastructure from the Chrome Next integration work in [#259318](#259318) into a focused PR. This adds: - `@kbn/app-header` shared package with inline and Chrome-owned app header rendering APIs. - `chrome.next.appHeader.set()` plus internal state, lifecycle cleanup, mocks, and layout wiring. - Chrome-owned app header rendering in the Chrome Next project layout. - Focused hardening for content detection, registration cleanup, legacy badge fallback, and public type exports. - Package README and targeted unit coverage for the new app-header behavior. This intentionally does not migrate any apps yet and does not pull in unrelated Chrome Next slices such as side nav, user menu, feedback handlers, or broader help menu changes. ## Context The original integration branch includes app migrations and additional Chrome Next features. This PR extracts only the app-header foundation so it can be reviewed and merged independently before route-by-route adoption. Follow-up created: [#271295](#271295) to make the static “Add integrations” action access-aware. ## Risk Low to medium. The new APIs are behind Chrome Next behavior and currently have no app adopters in this PR, but the changes touch shared Chrome layout state. Risk is mitigated with focused unit coverage and existing Chrome validation checks. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 02bc61d commit a34e41c

60 files changed

Lines changed: 2318 additions & 14 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ src/core/packages/capabilities/common @elastic/kibana-core
136136
src/core/packages/capabilities/server @elastic/kibana-core
137137
src/core/packages/capabilities/server-internal @elastic/kibana-core
138138
src/core/packages/capabilities/server-mocks @elastic/kibana-core
139+
src/core/packages/chrome/app-header @elastic/appex-sharedux
139140
src/core/packages/chrome/app-menu/core-chrome-app-menu @elastic/appex-sharedux
140141
src/core/packages/chrome/app-menu/core-chrome-app-menu-components @elastic/appex-sharedux
141142
src/core/packages/chrome/browser @elastic/appex-sharedux

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@
251251
"@kbn/apm-types-shared": "link:src/platform/packages/shared/kbn-apm-types-shared",
252252
"@kbn/apm-ui-shared": "link:src/platform/packages/shared/kbn-apm-ui-shared",
253253
"@kbn/apm-utils": "link:src/platform/packages/shared/kbn-apm-utils",
254+
"@kbn/app-header": "link:src/core/packages/chrome/app-header",
254255
"@kbn/app-link-test-plugin": "link:src/platform/test/plugin_functional/plugins/app_link_test",
255256
"@kbn/application-usage-test-plugin": "link:x-pack/platform/test/usage_collection/plugins/application_usage_test",
256257
"@kbn/as-code-data-views-schema": "link:src/platform/packages/shared/as-code/data-views-schema",

packages/kbn-optimizer/limits.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pageLoadAssetSize:
2929
contentConnectors: 33014
3030
contentManagement: 8350
3131
controls: 10300
32-
core: 548600
32+
core: 606422
3333
cps: 9209
3434
crossClusterReplication: 12662
3535
customIntegrations: 11715
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# @kbn/app-header
2+
3+
React APIs for Kibana app headers during the Chrome Next migration.
4+
5+
Chrome Next uses one shared header view with two placement models:
6+
7+
- App-owned inline rendering, where the page renders `AppHeader` in its own React tree.
8+
- Chrome-owned rendering, where the app registers `AppHeaderConfig` and Chrome renders the layout
9+
top-bar slot.
10+
11+
Prefer inline rendering for new migrations. Use Chrome-owned registration as a transitional path when
12+
the page cannot safely own the header placement yet.
13+
14+
## Which API should I use?
15+
16+
Use `AppHeader` when the page can render its header inline. This is the preferred model for pages
17+
that own their title, back target, tabs, badges, and app menu locally.
18+
19+
Use `AppHeaderWithFallback` when the same page still needs a classic `EuiPageHeader` fallback while
20+
Chrome Next is disabled.
21+
22+
Use `ChromeAppHeaderRegistration` when Chrome should own the top-bar slot. This keeps migration
23+
small for pages with sticky or shared top-nav constraints while still using the shared header view.
24+
25+
Use `useChromeAppHeaderRegistration` only for lower-level wrappers that need to compose registration
26+
with other hooks. Most apps should use `ChromeAppHeaderRegistration`.
27+
28+
Use `chrome.next.appHeader.set` only when a React adapter is not practical. It is the imperative
29+
primitive behind the React APIs.
30+
31+
## Chrome Next flag and runtime checks
32+
33+
Chrome layout code should use `isNextChrome(featureFlags)` from `@kbn/core-chrome-feature-flags` to
34+
decide which layout slots are active.
35+
36+
App-facing React code usually should not read the flag directly. `ChromeAppHeaderRegistration`
37+
registers only when Chrome Next is enabled and the active chrome style is project:
38+
39+
```ts
40+
chrome.next.isEnabled && chrome.getChromeStyle() === 'project';
41+
```
42+
43+
When this condition is false, registration is a no-op and the existing classic/project Chrome paths
44+
continue to own the header area.
45+
46+
## Migration guidance
47+
48+
Migrate route-by-route, not necessarily app-by-app. Different routes in the same plugin can use
49+
different buckets while the migration is in progress:
50+
51+
| Bucket | Preferred API | When to use |
52+
|---|---|---|
53+
| Inline-ready | `AppHeader` or `AppHeaderWithFallback` | The page can colocate header state with its React tree. |
54+
| Chrome-owned transitional | `ChromeAppHeaderRegistration` | Chrome should own the top-bar slot while the route keeps existing layout constraints. |
55+
| Fallback-only | Legacy Chrome state | Temporary safety net for routes that have not explicitly migrated. |
56+
57+
### Fallback-only
58+
59+
Chrome Next in project layout does not render the classic breadcrumbs UI. For unmigrated routes,
60+
Chrome can still render a minimal app header as a fallback by deriving:
61+
62+
- A back button from the closest usable breadcrumb.
63+
- A menu from `chrome.setAppMenu()` or a legacy `chrome.setHeaderActionMenu()` mount point.
64+
- Badges from legacy badge state.
65+
66+
This is a compatibility fallback, not a migration target. If breadcrumbs are missing, stale, or point
67+
to the wrong parent, the fallback back button inherits the same problem. Move routes in this bucket
68+
to explicit `AppHeader` or `ChromeAppHeaderRegistration` configuration.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
export { AppHeaderWithFallback, AppHeader } from './src';
11+
export { AppHeaderView, ChromeAppHeaderRegistration, useChromeAppHeaderRegistration } from './src';
12+
export type { AppHeaderViewProps, AppHeaderConfig } from './src';
13+
export type {
14+
AppHeaderWithFallbackProps,
15+
AppHeaderProps,
16+
AppHeaderBack,
17+
AppHeaderBadge,
18+
AppHeaderBadgeItem,
19+
AppHeaderTab,
20+
AppHeaderMenu,
21+
AppHeaderPadding,
22+
} from './src';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
module.exports = {
11+
preset: '@kbn/test',
12+
rootDir: '../../../../..',
13+
roots: ['<rootDir>/src/core/packages/chrome/app-header'],
14+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "shared-browser",
3+
"id": "@kbn/app-header",
4+
"owner": "@elastic/appex-sharedux",
5+
"group": "platform",
6+
"visibility": "shared"
7+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# This file is generated by the @kbn/moon package. Any manual edits will be erased!
2+
# To extend this, write your extensions/overrides to 'moon.extend.yml'
3+
# then regenerate this file with: 'node scripts/regenerate_moon_projects.js --update --filter @kbn/app-header'
4+
5+
$schema: https://moonrepo.dev/schemas/project.json
6+
id: '@kbn/app-header'
7+
layer: unknown
8+
owners:
9+
defaultOwner: '@elastic/appex-sharedux'
10+
toolchains:
11+
default: node
12+
language: typescript
13+
project:
14+
title: '@kbn/app-header'
15+
description: Moon project for @kbn/app-header
16+
channel: ''
17+
owner: '@elastic/appex-sharedux'
18+
sourceRoot: src/core/packages/chrome/app-header
19+
dependsOn:
20+
- '@kbn/core-chrome-app-menu-components'
21+
- '@kbn/core-chrome-browser'
22+
- '@kbn/core-chrome-browser-context'
23+
- '@kbn/core-http-browser'
24+
- '@kbn/core-mount-utils-browser'
25+
- '@kbn/i18n'
26+
- '@kbn/use-observable'
27+
- '@kbn/core-chrome-browser-internal-types'
28+
- '@kbn/core-chrome-browser-mocks'
29+
tags:
30+
- shared-browser
31+
- package
32+
- prod
33+
- group-platform
34+
- shared
35+
- jest-unit-tests
36+
fileGroups:
37+
src:
38+
- '**/*.ts'
39+
- '**/*.tsx'
40+
- '!target/**/*'
41+
jest-config:
42+
- jest.config.js
43+
tasks: {}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "@kbn/app-header",
3+
"private": true,
4+
"version": "1.0.0",
5+
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0",
6+
"sideEffects": false
7+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React, { useCallback, useMemo, useState } from 'react';
11+
import { EuiBadge, EuiContextMenu, EuiPopover, EuiToolTip } from '@elastic/eui';
12+
import type {
13+
EuiContextMenuPanelDescriptor,
14+
EuiContextMenuPanelItemDescriptor,
15+
} from '@elastic/eui';
16+
import { css } from '@emotion/react';
17+
import { i18n } from '@kbn/i18n';
18+
import type { AppHeaderBadge, AppHeaderBadgeItem } from '../types';
19+
20+
/**
21+
* Recursively builds flat EuiContextMenu panels from nested badge menu items.
22+
*/
23+
const buildPanels = (
24+
items: AppHeaderBadgeItem[],
25+
panelId: number,
26+
width?: number,
27+
title?: string
28+
): EuiContextMenuPanelDescriptor[] => {
29+
const panels: EuiContextMenuPanelDescriptor[] = [];
30+
let nextPanelId = panelId + 1;
31+
32+
const panelItems: EuiContextMenuPanelItemDescriptor[] = items.map((item) => {
33+
const { items: childItems, popoverWidth: childWidth, ...rest } = item;
34+
if (childItems && childItems.length > 0) {
35+
const childPanelId = nextPanelId;
36+
const childPanels = buildPanels(childItems, childPanelId, childWidth, item.name);
37+
nextPanelId = childPanelId + childPanels.length;
38+
panels.push(...childPanels);
39+
return { ...rest, panel: childPanelId };
40+
}
41+
42+
return rest;
43+
});
44+
45+
panels.unshift({
46+
id: panelId,
47+
items: panelItems,
48+
...(title && { title }),
49+
...(width && { width }),
50+
});
51+
52+
return panels;
53+
};
54+
55+
const useBadgeStyle = () => {
56+
return useMemo(() => {
57+
const badge = css`
58+
max-width: 200px;
59+
`;
60+
61+
return { badge };
62+
}, []);
63+
};
64+
65+
export const AppBadge = ({ badge }: { badge: AppHeaderBadge }) => {
66+
const { badge: badgeStyle } = useBadgeStyle();
67+
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
68+
69+
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
70+
const togglePopover = useCallback(() => setIsPopoverOpen((open) => !open), []);
71+
72+
if (badge?.renderCustomBadge) {
73+
// TODO: Remove custom JSX badge rendering once apps migrate custom badges to structured config.
74+
return badge.renderCustomBadge({ badgeText: badge.label });
75+
}
76+
77+
const hasItems = 'items' in badge && badge.items !== undefined;
78+
79+
const badgeOnClickAriaLabel =
80+
badge?.onClickAriaLabel ??
81+
i18n.translate('core.ui.chrome.appHeader.badge.ariaLabel', {
82+
defaultMessage: 'Click {label} badge',
83+
values: { label: badge.label },
84+
});
85+
86+
const handleBadgeClick = () => {
87+
if (hasItems) {
88+
togglePopover();
89+
return;
90+
}
91+
badge?.onClick?.();
92+
};
93+
94+
const badgeComponent = (
95+
<EuiBadge
96+
onClick={handleBadgeClick}
97+
onClickAriaLabel={badgeOnClickAriaLabel}
98+
color={badge?.color ?? 'hollow'}
99+
data-test-subj={badge?.['data-test-subj']}
100+
css={badgeStyle}
101+
iconType={hasItems ? 'arrowDown' : undefined}
102+
iconSide={hasItems ? 'right' : undefined}
103+
>
104+
{badge.label}
105+
</EuiBadge>
106+
);
107+
108+
const wrappedBadge = badge?.tooltip ? (
109+
<EuiToolTip content={badge.tooltip}>{badgeComponent}</EuiToolTip>
110+
) : (
111+
badgeComponent
112+
);
113+
114+
if (hasItems) {
115+
return (
116+
<EuiPopover
117+
button={wrappedBadge}
118+
isOpen={isPopoverOpen}
119+
closePopover={closePopover}
120+
panelPaddingSize="none"
121+
aria-label={badge.label}
122+
>
123+
<EuiContextMenu
124+
initialPanelId={0}
125+
panels={buildPanels(badge.items!, 0, badge.popoverWidth)}
126+
/>
127+
</EuiPopover>
128+
);
129+
}
130+
131+
return wrappedBadge;
132+
};
133+
134+
AppBadge.displayName = 'AppBadge';

0 commit comments

Comments
 (0)