diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a2a1e39e20bb..5c5111ae030a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ @@ -47,10 +47,12 @@ Example: +Note: Most PRs should be [tested in Kibana](https://github.com/elastic/eui/blob/main/wiki/contributing-to-eui/testing/testing-in-kibana.md) to help gauge their **Impact** before merging. + - [ ] πŸ”΄ **Breaking changes** β€” What will break? How many usages in Kibana/Cloud UI are impacted? - [ ] πŸ’… **Visual changes** β€” May impact style overrides; could require visual testing. Explain and estimate impact. - [ ] πŸ§ͺ **Test impact** β€” May break functional or snapshot tests (e.g., HTML structure, class names, default values). -- [ ] πŸ”§ **Hard to integrate** β€” If integration is complex, stage commits in Kibana/Cloud UI branches for cherry-picking and link to them below. +- [ ] πŸ”§ **Hard to integrate** β€” If changes require substantial updates to Kibana, please [stage the changes](https://github.com/elastic/eui/blob/main/wiki/contributing-to-eui/testing/testing-in-kibana.md#staging-integrations) and link them here. **Impact level:** 🟒 None / 🟒 Low / 🟑 Moderate / πŸ”΄ High diff --git a/README.md b/README.md index 54db578deb37..6576fe2a3de1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Yes! We welcome community-contributed PRs, especially around feature requests th ### What about reporting bugs and feature requests? -Bug reports and feature requests are most welcome, but our roadmap and prioritization are driven primarily by [internal Elastic usage](wiki/contributing-to-eui#how-we-assign-work-and-define-our-roadmap). +Bug reports and feature requests are most welcome, but our roadmap and prioritization are driven primarily by internal Elastic usage. Please note that in order to keep our backlog manageable and focused on tasks we intend to complete, feature requests & tech debt issues that are inactive for a year will be auto-closed (bugs will remain open if determined to be reproducible and valid). diff --git a/packages/eslint-plugin/changelogs/CHANGELOG_2026.md b/packages/eslint-plugin/changelogs/CHANGELOG_2026.md index e9ba3854a256..f2a722bc3fc8 100644 --- a/packages/eslint-plugin/changelogs/CHANGELOG_2026.md +++ b/packages/eslint-plugin/changelogs/CHANGELOG_2026.md @@ -1,3 +1,7 @@ +## [`v2.11.0`](https://github.com/elastic/eui/releases/v2.11.0) + +- Updated `no-unnamed-interactive-element` to include checking `EuiColorPicker` ([#9436](https://github.com/elastic/eui/pull/9436)) + ## [`v2.10.0`](https://github.com/elastic/eui/releases/v2.10.0) - Added `EuiPopover` and `EuiWrappingPopover` checks to `require-aria-label-for-modals`, requiring either `aria-label` or `aria-labelledby` for these popover components. ([#9427](https://github.com/elastic/eui/pull/9427)) diff --git a/packages/eslint-plugin/changelogs/upcoming/9368.md b/packages/eslint-plugin/changelogs/upcoming/9368.md new file mode 100644 index 000000000000..aa3dd7dee6a7 --- /dev/null +++ b/packages/eslint-plugin/changelogs/upcoming/9368.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed `no-css-color` ESLint rule crash when analyzing functions that return `undefined` or non-object values diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 5a4b87919371..70358915549e 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@elastic/eslint-plugin-eui", - "version": "2.10.0", + "version": "2.11.0", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts index 2588150fe624..30428956d051 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts @@ -44,6 +44,10 @@ ruleTester.run('NoUnnamedInteractiveElement', NoUnnamedInteractiveElement, { code: '', languageOptions, }, + { + code: '', + languageOptions, + }, // Wrapped in EuiFormRow with label { code: '', @@ -53,6 +57,10 @@ ruleTester.run('NoUnnamedInteractiveElement', NoUnnamedInteractiveElement, { code: '', languageOptions, }, + { + code: '', + languageOptions, + }, ], invalid: [ // Missing a11y prop for interactive components @@ -101,6 +109,11 @@ ruleTester.run('NoUnnamedInteractiveElement', NoUnnamedInteractiveElement, { languageOptions, errors: [{ messageId: 'missingA11y' }], }, + { + code: '', + languageOptions, + errors: [{ messageId: 'missingA11y' }], + }, // Wrapped but missing label { code: '', @@ -112,5 +125,10 @@ ruleTester.run('NoUnnamedInteractiveElement', NoUnnamedInteractiveElement, { languageOptions, errors: [{ messageId: 'missingA11y' }], }, + { + code: '', + languageOptions, + errors: [{ messageId: 'missingA11y' }], + }, ], }); diff --git a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts index c6425bd17d3c..a04f19c7d88c 100644 --- a/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts +++ b/packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts @@ -24,6 +24,7 @@ const interactiveComponents = [ 'EuiPagination', 'EuiTreeView', 'EuiBreadcrumbs', + 'EuiColorPicker', ] as const; const wrappingComponents = ['EuiFormRow'] as const; @@ -54,7 +55,10 @@ export const NoUnnamedInteractiveElement = ESLintUtils.RuleCreator.withoutDocs({ function report(opening: TSESTree.JSXOpeningElement) { if (opening.name.type !== 'JSXIdentifier') return; const component = opening.name.name; - const allowed = getAllowedA11yPropNamesForComponent(component, a11yConfig).join(', '); + const allowed = getAllowedA11yPropNamesForComponent( + component, + a11yConfig + ).join(', '); context.report({ node: opening, messageId: 'missingA11y', diff --git a/packages/eslint-plugin/src/rules/no_css_color.ts b/packages/eslint-plugin/src/rules/no_css_color.ts index 6be2f515381c..e47343734d6d 100644 --- a/packages/eslint-plugin/src/rules/no_css_color.ts +++ b/packages/eslint-plugin/src/rules/no_css_color.ts @@ -478,10 +478,20 @@ export const NoCssColor = ESLintUtils.RuleCreator.withoutDocs({ return; } + const returnArgument = ( + functionReturnStatementNode as TSESTree.ReturnStatement + ).argument; + + if ( + !returnArgument || + returnArgument.type !== 'ObjectExpression' + ) { + return; + } + declarationPropertiesNode = ( - (functionReturnStatementNode as TSESTree.ReturnStatement) - .argument as TSESTree.ObjectExpression - )?.properties.filter( + returnArgument as TSESTree.ObjectExpression + ).properties.filter( (property): property is TSESTree.Property => property.type === 'Property' ); diff --git a/packages/eui-theme-borealis/changelogs/CHANGELOG_2026.md b/packages/eui-theme-borealis/changelogs/CHANGELOG_2026.md index d8d5648b03dd..d4726ca70e30 100644 --- a/packages/eui-theme-borealis/changelogs/CHANGELOG_2026.md +++ b/packages/eui-theme-borealis/changelogs/CHANGELOG_2026.md @@ -1,3 +1,17 @@ +## [`v7.0.0`](https://github.com/elastic/eui/releases/v7.0.0) + +**Breaking changes** + +- Removed `severity.assistance` color token ([#9507](https://github.com/elastic/eui/pull/9507)) +- Removed assistance datavis color tokens: ([#9507](https://github.com/elastic/eui/pull/9507)) + - `vis.euiColorVisAssistance` + - `vis.euiColorVis10` + - `vis.euiColorVis11` + - `vis.euiColorVisText10` + - `vis.euiColorVisText11` + - `vis.euiColorVisBehindText10` + - `vis.euiColorVisBehindText11` + ## [`v6.2.0`](https://github.com/elastic/eui/releases/v6.2.0) - Adjusted lightest color tokens to get a more balanced palette and contrast with content over light backgrounds: ([#9432](https://github.com/elastic/eui/pull/9432)) diff --git a/packages/eui-theme-borealis/changelogs/upcoming/9507.md b/packages/eui-theme-borealis/changelogs/upcoming/9507.md deleted file mode 100644 index e02c993fc5e7..000000000000 --- a/packages/eui-theme-borealis/changelogs/upcoming/9507.md +++ /dev/null @@ -1,11 +0,0 @@ -**Breaking changes** - -- Removed `severity.assistance` color token -- Removed assistance datavis color tokens: - - `vis.euiColorVisAssistance` - - `vis.euiColorVis10` - - `vis.euiColorVis11` - - `vis.euiColorVisText10` - - `vis.euiColorVisText11` - - `vis.euiColorVisBehindText10` - - `vis.euiColorVisBehindText11` \ No newline at end of file diff --git a/packages/eui-theme-borealis/package.json b/packages/eui-theme-borealis/package.json index d5d14b0b38d1..5f1d8566efcc 100644 --- a/packages/eui-theme-borealis/package.json +++ b/packages/eui-theme-borealis/package.json @@ -1,6 +1,6 @@ { "name": "@elastic/eui-theme-borealis", - "version": "6.2.0", + "version": "7.0.0", "description": "A visual theme for EUI", "license": "SEE LICENSE IN LICENSE.txt", "scripts": { diff --git a/packages/eui-theme-common/changelogs/CHANGELOG_2026.md b/packages/eui-theme-common/changelogs/CHANGELOG_2026.md index b8567b7ad857..e85b33245b8d 100644 --- a/packages/eui-theme-common/changelogs/CHANGELOG_2026.md +++ b/packages/eui-theme-common/changelogs/CHANGELOG_2026.md @@ -1,3 +1,17 @@ +## [`v9.0.0`](https://github.com/elastic/eui/releases/v9.0.0) + +**Breaking changes** + +- Removed type for `severity.assistance` color token ([#9507](https://github.com/elastic/eui/pull/9507)) +- Removed types for assistance datavis color tokens: ([#9507](https://github.com/elastic/eui/pull/9507)) + - `vis.euiColorVisAssistance` + - `vis.euiColorVis10` + - `vis.euiColorVis11` + - `vis.euiColorVisText10` + - `vis.euiColorVisText11` + - `vis.euiColorVisBehindText10` + - `vis.euiColorVisBehindText11` + ## [`v8.1.0`](https://github.com/elastic/eui/releases/v8.1.0) - Added new assistance tokens: ([#9383](https://github.com/elastic/eui/pull/9383)) diff --git a/packages/eui-theme-common/changelogs/upcoming/9507.md b/packages/eui-theme-common/changelogs/upcoming/9507.md deleted file mode 100644 index ede06c215006..000000000000 --- a/packages/eui-theme-common/changelogs/upcoming/9507.md +++ /dev/null @@ -1,11 +0,0 @@ -**Breaking changes** - -- Removed type for `severity.assistance` color token -- Removed types for assistance datavis color tokens: - - `vis.euiColorVisAssistance` - - `vis.euiColorVis10` - - `vis.euiColorVis11` - - `vis.euiColorVisText10` - - `vis.euiColorVisText11` - - `vis.euiColorVisBehindText10` - - `vis.euiColorVisBehindText11` \ No newline at end of file diff --git a/packages/eui-theme-common/package.json b/packages/eui-theme-common/package.json index a45a73055259..9b99990b65ca 100644 --- a/packages/eui-theme-common/package.json +++ b/packages/eui-theme-common/package.json @@ -1,6 +1,6 @@ { "name": "@elastic/eui-theme-common", - "version": "8.1.0", + "version": "9.0.0", "description": "EUI theme common", "license": "SEE LICENSE IN LICENSE.txt", "scripts": { diff --git a/packages/eui/.eslintrc.js b/packages/eui/.eslintrc.js index 130d9b577358..3f761f7b57fe 100644 --- a/packages/eui/.eslintrc.js +++ b/packages/eui/.eslintrc.js @@ -37,6 +37,7 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:deprecation/recommended', 'plugin:storybook/recommended', + 'plugin:@elastic/eui/recommended', // Prettier options need to come last, in order to override other style rules 'plugin:prettier/recommended', ], @@ -48,7 +49,8 @@ module.exports = { 'react', 'react-hooks', '@emotion', - 'deprecation' + 'deprecation', + '@elastic/eui' ], rules: { 'deprecation/deprecation': 'warn', diff --git a/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png b/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png index fa78d8996175..f24c67633f97 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png and b/packages/eui/.loki/reference/chrome_desktop_Display_EuiImage_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiFilePicker_Controlled_With_Files.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiFilePicker_Controlled_With_Files.png new file mode 100644 index 000000000000..52a152ef56eb Binary files /dev/null and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiFilePicker_Controlled_With_Files.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png index d01c3ac63611..40d5af29a389 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_High_Contrast_Dark_Mode.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png index ff5204cb6d46..a4653c7c96da 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlButton_Kitchensink.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png index e53a0b405654..f64933d70276 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_High_Contrast.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png index 87c8443cfcb2..016041e088af 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayoutDelimited_Kitchen_Sink.png differ diff --git a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png index ee0a30927a83..81e664e81d31 100644 Binary files a/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png and b/packages/eui/.loki/reference/chrome_desktop_Forms_EuiForm_EuiFormControlLayout_High_Contrast_Dark_Mode.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png b/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png index d3a0f5f98762..46439be9ada0 100644 Binary files a/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png and b/packages/eui/.loki/reference/chrome_mobile_Display_EuiImage_Playground.png differ diff --git a/packages/eui/.loki/reference/chrome_mobile_Forms_EuiFilePicker_Controlled_With_Files.png b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiFilePicker_Controlled_With_Files.png new file mode 100644 index 000000000000..b4325f29f902 Binary files /dev/null and b/packages/eui/.loki/reference/chrome_mobile_Forms_EuiFilePicker_Controlled_With_Files.png differ diff --git a/packages/eui/changelogs/CHANGELOG_2026.md b/packages/eui/changelogs/CHANGELOG_2026.md index 0bd5add51de2..1b0a09dcbdad 100644 --- a/packages/eui/changelogs/CHANGELOG_2026.md +++ b/packages/eui/changelogs/CHANGELOG_2026.md @@ -1,3 +1,40 @@ +## [`v114.0.0`](https://github.com/elastic/eui/releases/v114.0.0) + +- Fixed the clipping of `EuiFlyout` overlay mask to the container bounds when the `container` prop is provided, so the mask no longer covers the full viewport for app-scoped flyouts. ([#9512](https://github.com/elastic/eui/pull/9512)) +- Updated `EuiFlyout` to support `pushAnimation` prop for `type="overlay"` ([#9428](https://github.com/elastic/eui/pull/9428)) +- Added `hasAnimation` prop on `EuiFlyout` (replaces `pushAnimation`) ([#9428](https://github.com/elastic/eui/pull/9428)) +- Added `hasAnimation` prop on `EuiOverlayMask` to conditionally add animation styles ([#9428](https://github.com/elastic/eui/pull/9428)) +- Added `historyKey` prop (type `symbol`) to `EuiFlyout` and the flyout manager API to support scoped flyout history. ([#9413](https://github.com/elastic/eui/pull/9413)) + - Only flyouts sharing the same `Symbol` reference share Back button navigation and history entries; omitting `historyKey` gives each session its own isolated history group. + - `ACTION_CLOSE_ALL` now closes only the current history group rather than all open flyouts. + +**Bug fixes** + +- Fixed `EuiTreeView` expanded nodes clipping content and causing sibling overlap when children exceed viewport height ([#9510](https://github.com/elastic/eui/pull/9510)) +- Fixed `EuiDataGrid` scroll bouncing back to the focused element in certain cases ([#9453](https://github.com/elastic/eui/pull/9453)) +- Fixed support for intraword underscores in `EuiMarkdownFormat` ([#9408](https://github.com/elastic/eui/pull/9408)) + +**Deprecations** + +- Deprecated `pushAnimation` prop on `EuiFlyout`. Use `hasAnimation` instead. ([#9428](https://github.com/elastic/eui/pull/9428)) + +**Breaking changes** + +- Removed `severity.assistance` color token ([#9507](https://github.com/elastic/eui/pull/9507)) +- Removed assistance datavis color tokens: ([#9507](https://github.com/elastic/eui/pull/9507)) + - `vis.euiColorVisAssistance` + - `vis.euiColorVis10` + - `vis.euiColorVis11` + - `vis.euiColorVisText10` + - `vis.euiColorVisText11` + - `vis.euiColorVisBehindText10` + - `vis.euiColorVisBehindText11` +- The positional signature of `FlyoutManagerApi.addFlyout` and the flyout store's `addFlyout` now includes `historyKey` before the `iconType`/`minWidth` arguments. Call sites that pass arguments positionally must be updated (or switched to named parameters) to account for this new parameter ordering. ([#9413](https://github.com/elastic/eui/pull/9413)) + +**Accessibility** + +- Fixed `aria-label` not being applied to `EuiColorPicker`'s input element ([#9436](https://github.com/elastic/eui/pull/9436)) + ## [`v113.3.0`](https://github.com/elastic/eui/releases/v113.3.0) - Added `color` prop to `EuiContextMenuItem`, accepting all standard button color values ([#9448](https://github.com/elastic/eui/pull/9448)) diff --git a/packages/eui/changelogs/upcoming/9428.md b/packages/eui/changelogs/upcoming/9428.md deleted file mode 100644 index e239c3e93d72..000000000000 --- a/packages/eui/changelogs/upcoming/9428.md +++ /dev/null @@ -1,7 +0,0 @@ -- Updated `EuiFlyout` to support `pushAnimation` prop for `type="overlay"` -- Added `hasAnimation` prop on `EuiFlyout` (replaces `pushAnimation`) -- Added `hasAnimation` prop on `EuiOverlayMask` to conditionally add animation styles - -**Deprecations** - -- Deprecated `pushAnimation` prop on `EuiFlyout`. Use `hasAnimation` instead. \ No newline at end of file diff --git a/packages/eui/changelogs/upcoming/9507.md b/packages/eui/changelogs/upcoming/9507.md deleted file mode 100644 index e02c993fc5e7..000000000000 --- a/packages/eui/changelogs/upcoming/9507.md +++ /dev/null @@ -1,11 +0,0 @@ -**Breaking changes** - -- Removed `severity.assistance` color token -- Removed assistance datavis color tokens: - - `vis.euiColorVisAssistance` - - `vis.euiColorVis10` - - `vis.euiColorVis11` - - `vis.euiColorVisText10` - - `vis.euiColorVisText11` - - `vis.euiColorVisBehindText10` - - `vis.euiColorVisBehindText11` \ No newline at end of file diff --git a/packages/eui/changelogs/upcoming/9510.md b/packages/eui/changelogs/upcoming/9510.md deleted file mode 100644 index 587e6eab223d..000000000000 --- a/packages/eui/changelogs/upcoming/9510.md +++ /dev/null @@ -1,3 +0,0 @@ -**Bug fixes** - -- Fixed `EuiTreeView` expanded nodes clipping content and causing sibling overlap when children exceed viewport height diff --git a/packages/eui/changelogs/upcoming/9514.md b/packages/eui/changelogs/upcoming/9514.md new file mode 100644 index 000000000000..f793c7fd53ba --- /dev/null +++ b/packages/eui/changelogs/upcoming/9514.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed `EuiFlyoutManager` animation flickering when switching between flyout sessions by removing intermediate transition stages (backgrounding, returning, closing) and limiting opening animations to the initial flyout and first child only diff --git a/packages/eui/changelogs/upcoming/9528.md b/packages/eui/changelogs/upcoming/9528.md new file mode 100644 index 000000000000..8424900d64a2 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9528.md @@ -0,0 +1 @@ +- Added `transitionBottomIn` and `transitionBottomOut` icons to `EuiIcon` diff --git a/packages/eui/changelogs/upcoming/9531.md b/packages/eui/changelogs/upcoming/9531.md new file mode 100644 index 000000000000..40c8cdf7e104 --- /dev/null +++ b/packages/eui/changelogs/upcoming/9531.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed rendering of `EuiTableHeaderMobile` when `responsiveBreakpoint=false` prop is set diff --git a/packages/eui/changelogs/upcoming/9540.md b/packages/eui/changelogs/upcoming/9540.md new file mode 100644 index 000000000000..c6d1333020cc --- /dev/null +++ b/packages/eui/changelogs/upcoming/9540.md @@ -0,0 +1 @@ +- Added a warning when non-recommended units are used in `width`, `minWidth` or `maxWidth` props on ``, ``, `` as well as the `columns` configuration on `` and `` diff --git a/packages/eui/changelogs/upcoming/9546.md b/packages/eui/changelogs/upcoming/9546.md new file mode 100644 index 000000000000..c825773e85dc --- /dev/null +++ b/packages/eui/changelogs/upcoming/9546.md @@ -0,0 +1 @@ +- Added an optional `tooltipProps` prop to `EuiMarkdownEditorHelpButton` diff --git a/packages/eui/i18ntokens.json b/packages/eui/i18ntokens.json index 851a2e863bb6..3447c760a494 100644 --- a/packages/eui/i18ntokens.json +++ b/packages/eui/i18ntokens.json @@ -251,42 +251,6 @@ }, "filepath": "src/components/toast/global_toast_list.tsx" }, - { - "token": "euiTableHeaderCell.titleTextWithDesc", - "defString": "{innerText}; {description}", - "highlighting": "string", - "loc": { - "start": { - "line": 139, - "column": 10, - "index": 4414 - }, - "end": { - "line": 143, - "column": 11, - "index": 4588 - } - }, - "filepath": "src/components/table/table_header_cell.tsx" - }, - { - "token": "euiStat.loadingText", - "defString": "Statistic is loading", - "highlighting": "string", - "loc": { - "start": { - "line": 95, - "column": 32, - "index": 2317 - }, - "end": { - "line": 98, - "column": 3, - "index": 2386 - } - }, - "filepath": "src/components/stat/stat.tsx" - }, { "token": "euiStepStrings.step", "defString": "Step {number}: {title}", @@ -576,40 +540,40 @@ "filepath": "src/components/steps/step_strings.tsx" }, { - "token": "euiSkeletonLoading.loadedAriaText", - "defString": "Loaded {contentAriaLabel}", + "token": "euiTableHeaderCell.titleTextWithDesc", + "defString": "{innerText}; {description}", "highlighting": "string", "loc": { "start": { - "line": 73, - "column": 25, - "index": 2343 + "line": 139, + "column": 10, + "index": 4414 }, "end": { - "line": 77, - "column": 3, - "index": 2457 + "line": 143, + "column": 11, + "index": 4588 } }, - "filepath": "src/components/skeleton/skeleton_loading.tsx" + "filepath": "src/components/table/table_header_cell.tsx" }, { - "token": "euiSkeletonLoading.loadingAriaText", - "defString": "Loading {contentAriaLabel}", + "token": "euiStat.loadingText", + "defString": "Statistic is loading", "highlighting": "string", "loc": { "start": { - "line": 79, - "column": 27, - "index": 2487 + "line": 95, + "column": 32, + "index": 2317 }, "end": { - "line": 83, + "line": 98, "column": 3, - "index": 2603 + "index": 2386 } }, - "filepath": "src/components/skeleton/skeleton_loading.tsx" + "filepath": "src/components/stat/stat.tsx" }, { "token": "euiSelectable.loadingOptions", @@ -738,22 +702,40 @@ "filepath": "src/components/selectable/selectable.tsx" }, { - "token": "euiSideNav.mobileToggleAriaLabel", - "defString": "Toggle navigation", + "token": "euiSkeletonLoading.loadedAriaText", + "defString": "Loaded {contentAriaLabel}", "highlighting": "string", "loc": { "start": { - "line": 208, - "column": 16, - "index": 6318 + "line": 73, + "column": 25, + "index": 2343 }, "end": { - "line": 211, - "column": 17, - "index": 6449 + "line": 77, + "column": 3, + "index": 2457 } }, - "filepath": "src/components/side_nav/side_nav.tsx" + "filepath": "src/components/skeleton/skeleton_loading.tsx" + }, + { + "token": "euiSkeletonLoading.loadingAriaText", + "defString": "Loading {contentAriaLabel}", + "highlighting": "string", + "loc": { + "start": { + "line": 79, + "column": 27, + "index": 2487 + }, + "end": { + "line": 83, + "column": 3, + "index": 2603 + } + }, + "filepath": "src/components/skeleton/skeleton_loading.tsx" }, { "token": "euiSearchBox.placeholder", @@ -809,6 +791,24 @@ }, "filepath": "src/components/search_bar/search_box.tsx" }, + { + "token": "euiSideNav.mobileToggleAriaLabel", + "defString": "Toggle navigation", + "highlighting": "string", + "loc": { + "start": { + "line": 208, + "column": 16, + "index": 6318 + }, + "end": { + "line": 211, + "column": 17, + "index": 6449 + } + }, + "filepath": "src/components/side_nav/side_nav.tsx" + }, { "token": "euiResizablePanel.toggleButtonAriaLabel", "defString": "Press to toggle this panel", @@ -1151,42 +1151,6 @@ }, "filepath": "src/components/pagination/pagination.tsx" }, - { - "token": "euiMark.highlightStart", - "defString": "highlight start", - "highlighting": "string", - "loc": { - "start": { - "line": 44, - "column": 25, - "index": 1289 - }, - "end": { - "line": 47, - "column": 3, - "index": 1356 - } - }, - "filepath": "src/components/mark/mark.tsx" - }, - { - "token": "euiMark.highlightEnd", - "defString": "highlight end", - "highlighting": "string", - "loc": { - "start": { - "line": 48, - "column": 23, - "index": 1381 - }, - "end": { - "line": 48, - "column": 74, - "index": 1432 - } - }, - "filepath": "src/components/mark/mark.tsx" - }, { "token": "euiModal.screenReaderModalDialog", "defString": "You are in a modal dialog. Press Escape or tap/click outside the dialog on the shadowed overlay to close.", @@ -1223,6 +1187,42 @@ }, "filepath": "src/components/modal/modal.tsx" }, + { + "token": "euiMark.highlightStart", + "defString": "highlight start", + "highlighting": "string", + "loc": { + "start": { + "line": 44, + "column": 25, + "index": 1289 + }, + "end": { + "line": 47, + "column": 3, + "index": 1356 + } + }, + "filepath": "src/components/mark/mark.tsx" + }, + { + "token": "euiMark.highlightEnd", + "defString": "highlight end", + "highlighting": "string", + "loc": { + "start": { + "line": 48, + "column": 23, + "index": 1381 + }, + "end": { + "line": 48, + "column": 74, + "index": 1432 + } + }, + "filepath": "src/components/mark/mark.tsx" + }, { "token": "euiMarkdownEditorToolbar.editor", "defString": "Editor", @@ -1547,6 +1547,42 @@ }, "filepath": "src/components/link/external_link_icon.tsx" }, + { + "token": "euiImageButton.openFullScreen", + "defString": "Click to open this image in fullscreen mode", + "highlighting": "string", + "loc": { + "start": { + "line": 57, + "column": 37, + "index": 1585 + }, + "end": { + "line": 60, + "column": 3, + "index": 1687 + } + }, + "filepath": "src/components/image/image_button.tsx" + }, + { + "token": "euiImageButton.closeFullScreen", + "defString": "Press Escape or click to close image fullscreen mode", + "highlighting": "string", + "loc": { + "start": { + "line": 61, + "column": 38, + "index": 1727 + }, + "end": { + "line": 64, + "column": 3, + "index": 1839 + } + }, + "filepath": "src/components/image/image_button.tsx" + }, { "token": "euiInlineEditForm.saveButtonAriaLabel", "defString": "Save edit", @@ -1620,109 +1656,37 @@ "filepath": "src/components/inline_edit/inline_edit_form.tsx" }, { - "token": "euiImageButton.openFullScreen", - "defString": "Click to open this image in fullscreen mode", + "token": "euiForm.addressFormErrors", + "defString": "Please address the highlighted errors.", "highlighting": "string", "loc": { "start": { - "line": 57, - "column": 37, - "index": 1585 + "line": 98, + "column": 8, + "index": 2588 }, "end": { - "line": 60, - "column": 3, - "index": 1687 + "line": 101, + "column": 9, + "index": 2709 } }, - "filepath": "src/components/image/image_button.tsx" + "filepath": "src/components/form/form.tsx" }, { - "token": "euiImageButton.closeFullScreen", - "defString": "Press Escape or click to close image fullscreen mode", + "token": "euiFlyoutMenu.back", + "defString": "Back", "highlighting": "string", "loc": { "start": { - "line": 61, - "column": 38, - "index": 1727 - }, - "end": { - "line": 64, - "column": 3, - "index": 1839 - } - }, - "filepath": "src/components/image/image_button.tsx" - }, - { - "token": "euiForm.addressFormErrors", - "defString": "Please address the highlighted errors.", - "highlighting": "string", - "loc": { - "start": { - "line": 98, - "column": 8, - "index": 2588 - }, - "end": { - "line": 101, - "column": 9, - "index": 2709 - } - }, - "filepath": "src/components/form/form.tsx" - }, - { - "token": "euiFilterButton.filterBadgeActiveAriaLabel", - "defString": "{count} active filters", - "highlighting": "string", - "loc": { - "start": { - "line": 175, - "column": 27, - "index": 5404 - }, - "end": { - "line": 179, - "column": 3, - "index": 5525 - } - }, - "filepath": "src/components/filter_group/filter_button.tsx" - }, - { - "token": "euiFilterButton.filterBadgeAvailableAriaLabel", - "defString": "{count} available filters", - "highlighting": "string", - "loc": { - "start": { - "line": 180, - "column": 30, - "index": 5557 - }, - "end": { - "line": 184, - "column": 3, - "index": 5684 - } - }, - "filepath": "src/components/filter_group/filter_button.tsx" - }, - { - "token": "euiFlyoutMenu.back", - "defString": "Back", - "highlighting": "string", - "loc": { - "start": { - "line": 127, + "line": 138, "column": 6, - "index": 3596 + "index": 3891 }, "end": { - "line": 127, + "line": 138, "column": 59, - "index": 3649 + "index": 3944 } }, "filepath": "src/components/flyout/flyout_menu.tsx" @@ -1733,14 +1697,14 @@ "highlighting": "string", "loc": { "start": { - "line": 146, + "line": 157, "column": 22, - "index": 4066 + "index": 4361 }, "end": { - "line": 146, + "line": 157, "column": 68, - "index": 4112 + "index": 4407 } }, "filepath": "src/components/flyout/flyout_menu.tsx" @@ -1751,14 +1715,14 @@ "highlighting": "string", "loc": { "start": { - "line": 963, + "line": 1009, "column": 14, - "index": 34142 + "index": 35844 }, "end": { - "line": 966, + "line": 1012, "column": 16, - "index": 34357 + "index": 36059 } }, "filepath": "src/components/flyout/flyout.component.tsx" @@ -1769,14 +1733,14 @@ "highlighting": "string", "loc": { "start": { - "line": 968, + "line": 1014, "column": 14, - "index": 34390 + "index": 36092 }, "end": { - "line": 971, + "line": 1017, "column": 16, - "index": 34569 + "index": 36271 } }, "filepath": "src/components/flyout/flyout.component.tsx" @@ -1787,14 +1751,14 @@ "highlighting": "string", "loc": { "start": { - "line": 974, + "line": 1020, "column": 14, - "index": 34649 + "index": 36351 }, "end": { - "line": 977, + "line": 1023, "column": 16, - "index": 34830 + "index": 36532 } }, "filepath": "src/components/flyout/flyout.component.tsx" @@ -1817,6 +1781,42 @@ }, "filepath": "src/components/flyout/_flyout_close_button.tsx" }, + { + "token": "euiFilterButton.filterBadgeActiveAriaLabel", + "defString": "{count} active filters", + "highlighting": "string", + "loc": { + "start": { + "line": 175, + "column": 27, + "index": 5404 + }, + "end": { + "line": 179, + "column": 3, + "index": 5525 + } + }, + "filepath": "src/components/filter_group/filter_button.tsx" + }, + { + "token": "euiFilterButton.filterBadgeAvailableAriaLabel", + "defString": "{count} available filters", + "highlighting": "string", + "loc": { + "start": { + "line": 180, + "column": 30, + "index": 5557 + }, + "end": { + "line": 184, + "column": 3, + "index": 5684 + } + }, + "filepath": "src/components/filter_group/filter_button.tsx" + }, { "token": "euiErrorBoundary.error", "defString": "Error", @@ -1890,58 +1890,22 @@ "filepath": "src/components/datagrid/data_grid.tsx" }, { - "token": "euiHue.ariaValueText", - "defString": "Hue", - "highlighting": "string", - "loc": { - "start": { - "line": 47, - "column": 47, - "index": 1268 - }, - "end": { - "line": 50, - "column": 3, - "index": 1369 - } - }, - "filepath": "src/components/color_picker/hue.tsx" - }, - { - "token": "euiHue.ariaRoleDescription", - "defString": "Hue slider", - "highlighting": "string", - "loc": { - "start": { - "line": 47, - "column": 47, - "index": 1268 - }, - "end": { - "line": 50, - "column": 3, - "index": 1369 - } - }, - "filepath": "src/components/color_picker/hue.tsx" - }, - { - "token": "euiHue.label", - "defString": "Select the HSV color mode 'hue' value", + "token": "euiComboBox.listboxAriaLabel", + "defString": "Choose from the following options", "highlighting": "string", "loc": { "start": { - "line": 65, - "column": 10, - "index": 1826 + "line": 813, + "column": 8, + "index": 24080 }, "end": { - "line": 68, - "column": 12, - "index": 1940 + "line": 816, + "column": 9, + "index": 24199 } }, - "filepath": "src/components/color_picker/hue.tsx" + "filepath": "src/components/combo_box/combo_box.tsx" }, { "token": "euiSaturation.ariaLabel", @@ -1997,6 +1961,60 @@ }, "filepath": "src/components/color_picker/saturation.tsx" }, + { + "token": "euiHue.ariaValueText", + "defString": "Hue", + "highlighting": "string", + "loc": { + "start": { + "line": 47, + "column": 47, + "index": 1268 + }, + "end": { + "line": 50, + "column": 3, + "index": 1369 + } + }, + "filepath": "src/components/color_picker/hue.tsx" + }, + { + "token": "euiHue.ariaRoleDescription", + "defString": "Hue slider", + "highlighting": "string", + "loc": { + "start": { + "line": 47, + "column": 47, + "index": 1268 + }, + "end": { + "line": 50, + "column": 3, + "index": 1369 + } + }, + "filepath": "src/components/color_picker/hue.tsx" + }, + { + "token": "euiHue.label", + "defString": "Select the HSV color mode 'hue' value", + "highlighting": "string", + "loc": { + "start": { + "line": 65, + "column": 10, + "index": 1826 + }, + "end": { + "line": 68, + "column": 12, + "index": 1940 + } + }, + "filepath": "src/components/color_picker/hue.tsx" + }, { "token": "euiColorPickerSwatch.ariaLabel", "defString": "Select {color} as the color", @@ -2021,14 +2039,14 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" @@ -2039,14 +2057,14 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" @@ -2057,14 +2075,14 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" @@ -2075,14 +2093,14 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" @@ -2093,14 +2111,14 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" @@ -2111,14 +2129,14 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" @@ -2129,14 +2147,14 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" @@ -2147,53 +2165,53 @@ "highlighting": "string", "loc": { "start": { - "line": 222, + "line": 227, "column": 6, - "index": 5961 + "index": 6102 }, "end": { - "line": 243, + "line": 250, "column": 3, - "index": 6586 + "index": 6785 } }, "filepath": "src/components/color_picker/color_picker.tsx" }, { - "token": "euiCollapsibleNavBeta.ariaLabel", - "defString": "Site menu", + "token": "euiColorPicker.ariaLabel", + "defString": "Select a color", "highlighting": "string", "loc": { "start": { - "line": 181, - "column": 27, - "index": 6041 + "line": 227, + "column": 6, + "index": 6102 }, "end": { - "line": 184, + "line": 250, "column": 3, - "index": 6111 + "index": 6785 } }, - "filepath": "src/components/collapsible_nav_beta/collapsible_nav_beta.tsx" + "filepath": "src/components/color_picker/color_picker.tsx" }, { - "token": "euiComboBox.listboxAriaLabel", - "defString": "Choose from the following options", + "token": "euiCollapsibleNavBeta.ariaLabel", + "defString": "Site menu", "highlighting": "string", "loc": { "start": { - "line": 813, - "column": 8, - "index": 24080 + "line": 181, + "column": 27, + "index": 6041 }, "end": { - "line": 816, - "column": 9, - "index": 24199 + "line": 184, + "column": 3, + "index": 6111 } }, - "filepath": "src/components/combo_box/combo_box.tsx" + "filepath": "src/components/collapsible_nav_beta/collapsible_nav_beta.tsx" }, { "token": "euiCodeBlockFullScreen.fullscreenCollapse", @@ -2249,24 +2267,6 @@ }, "filepath": "src/components/code/code_block_full_screen.tsx" }, - { - "token": "euiCodeBlockAnnotations.ariaLabel", - "defString": "Click to view a code annotation for line {lineNumber}", - "highlighting": "string", - "loc": { - "start": { - "line": 37, - "column": 20, - "index": 1198 - }, - "end": { - "line": 41, - "column": 3, - "index": 1334 - } - }, - "filepath": "src/components/code/code_block_annotations.tsx" - }, { "token": "euiCodeBlockCopy.copy", "defString": "Copy", @@ -2285,6 +2285,24 @@ }, "filepath": "src/components/code/code_block_copy.tsx" }, + { + "token": "euiCodeBlockAnnotations.ariaLabel", + "defString": "Click to view a code annotation for line {lineNumber}", + "highlighting": "string", + "loc": { + "start": { + "line": 37, + "column": 20, + "index": 1198 + }, + "end": { + "line": 41, + "column": 3, + "index": 1334 + } + }, + "filepath": "src/components/code/code_block_annotations.tsx" + }, { "token": "euiCodeBlock.label", "defString": "{language} code block:", @@ -3173,14 +3191,14 @@ "highlighting": "string", "loc": { "start": { - "line": 76, + "line": 87, "column": 6, - "index": 2335 + "index": 2900 }, "end": { - "line": 79, + "line": 90, "column": 8, - "index": 2442 + "index": 3007 } }, "filepath": "src/components/form/file_picker/file_picker.tsx" @@ -3191,14 +3209,14 @@ "highlighting": "string", "loc": { "start": { - "line": 100, - "column": 10, - "index": 2840 + "line": 108, + "column": 8, + "index": 3364 }, "end": { - "line": 104, - "column": 12, - "index": 3022 + "line": 112, + "column": 10, + "index": 3523 } }, "filepath": "src/components/form/file_picker/file_picker.tsx" @@ -3209,14 +3227,14 @@ "highlighting": "string", "loc": { "start": { - "line": 146, + "line": 189, "column": 6, - "index": 3885 + "index": 5225 }, "end": { - "line": 149, + "line": 192, "column": 7, - "index": 3995 + "index": 5335 } }, "filepath": "src/components/form/file_picker/file_picker.tsx" @@ -3227,14 +3245,14 @@ "highlighting": "string", "loc": { "start": { - "line": 263, + "line": 307, "column": 18, - "index": 7934 + "index": 9342 }, "end": { - "line": 266, + "line": 310, "column": 20, - "index": 8057 + "index": 9465 } }, "filepath": "src/components/form/file_picker/file_picker.tsx" @@ -3299,14 +3317,14 @@ "highlighting": "string", "loc": { "start": { - "line": 158, + "line": 161, "column": 25, - "index": 5181 + "index": 5271 }, "end": { - "line": 161, + "line": 164, "column": 5, - "index": 5260 + "index": 5350 } }, "filepath": "src/components/flyout/manager/flyout_managed.tsx" @@ -5759,24 +5777,6 @@ }, "filepath": "src/components/datagrid/controls/fullscreen_selector.tsx" }, - { - "token": "euiDataGridToolbarControl.badgeAriaLabel", - "defString": "Active: {count}", - "highlighting": "string", - "loc": { - "start": { - "line": 31, - "column": 25, - "index": 1211 - }, - "end": { - "line": 40, - "column": 3, - "index": 1441 - } - }, - "filepath": "src/components/datagrid/controls/data_grid_toolbar_control.tsx" - }, { "token": "euiDisplaySelector.densityLabel", "defString": "Density", @@ -5958,202 +5958,22 @@ "filepath": "src/components/datagrid/controls/display_selector.tsx" }, { - "token": "euiColumnSorting.button", - "defString": "Sort fields", - "highlighting": "string", - "loc": { - "start": { - "line": 66, - "column": 30, - "index": 2120 - }, - "end": { - "line": 69, - "column": 5, - "index": 2190 - } - }, - "filepath": "src/components/datagrid/controls/column_sorting.tsx" - }, - { - "token": "euiColumnSorting.sortFieldAriaLabel", - "defString": "Sort by: ", - "highlighting": "string", - "loc": { - "start": { - "line": 70, - "column": 31, - "index": 2223 - }, - "end": { - "line": 73, - "column": 5, - "index": 2303 - } - }, - "filepath": "src/components/datagrid/controls/column_sorting.tsx" - }, - { - "token": "euiColumnSorting.emptySorting", - "defString": "Currently no fields are sorted", - "highlighting": "string", - "loc": { - "start": { - "line": 220, - "column": 14, - "index": 6937 - }, - "end": { - "line": 223, - "column": 16, - "index": 7073 - } - }, - "filepath": "src/components/datagrid/controls/column_sorting.tsx" - }, - { - "token": "euiColumnSorting.pickFields", - "defString": "Pick fields to sort by", - "highlighting": "string", - "loc": { - "start": { - "line": 253, - "column": 24, - "index": 8304 - }, - "end": { - "line": 256, - "column": 26, - "index": 8460 - } - }, - "filepath": "src/components/datagrid/controls/column_sorting.tsx" - }, - { - "token": "euiColumnSorting.clearAll", - "defString": "Clear sorting", - "highlighting": "string", - "loc": { - "start": { - "line": 328, - "column": 20, - "index": 11814 - }, - "end": { - "line": 331, - "column": 22, - "index": 11947 - } - }, - "filepath": "src/components/datagrid/controls/column_sorting.tsx" - }, - { - "token": "euiColumnSelector.dragHandleAriaLabel", - "defString": "drag handle", + "token": "euiDataGridToolbarControl.badgeAriaLabel", + "defString": "Active: {count}", "highlighting": "string", "loc": { "start": { - "line": 164, - "column": 30, - "index": 5119 + "line": 31, + "column": 25, + "index": 1211 }, "end": { - "line": 167, + "line": 40, "column": 3, - "index": 5197 - } - }, - "filepath": "src/components/datagrid/controls/column_selector.tsx" - }, - { - "token": "euiColumnSelector.button", - "defString": "Columns", - "highlighting": "string", - "loc": { - "start": { - "line": 203, - "column": 12, - "index": 6382 - }, - "end": { - "line": 203, - "column": 74, - "index": 6444 - } - }, - "filepath": "src/components/datagrid/controls/column_selector.tsx" - }, - { - "token": "euiColumnSelector.search", - "defString": "Search", - "highlighting": "string", - "loc": { - "start": { - "line": 209, - "column": 12, - "index": 6590 - }, - "end": { - "line": 215, - "column": 13, - "index": 6802 - } - }, - "filepath": "src/components/datagrid/controls/column_selector.tsx" - }, - { - "token": "euiColumnSelector.searchcolumns", - "defString": "Search columns", - "highlighting": "string", - "loc": { - "start": { - "line": 209, - "column": 12, - "index": 6590 - }, - "end": { - "line": 215, - "column": 13, - "index": 6802 - } - }, - "filepath": "src/components/datagrid/controls/column_selector.tsx" - }, - { - "token": "euiColumnSelector.selectAll", - "defString": "Show all", - "highlighting": "string", - "loc": { - "start": { - "line": 340, - "column": 18, - "index": 12118 - }, - "end": { - "line": 343, - "column": 20, - "index": 12242 - } - }, - "filepath": "src/components/datagrid/controls/column_selector.tsx" - }, - { - "token": "euiColumnSelector.hideAll", - "defString": "Hide all", - "highlighting": "string", - "loc": { - "start": { - "line": 353, - "column": 18, - "index": 12602 - }, - "end": { - "line": 356, - "column": 20, - "index": 12724 + "index": 1441 } }, - "filepath": "src/components/datagrid/controls/column_selector.tsx" + "filepath": "src/components/datagrid/controls/data_grid_toolbar_control.tsx" }, { "token": "euiColumnSortingDraggable.defaultSortAsc", @@ -6264,130 +6084,220 @@ "filepath": "src/components/datagrid/controls/column_sorting_draggable.tsx" }, { - "token": "euiCollapsibleNavButton.ariaLabelExpand", - "defString": "Expand navigation", + "token": "euiColumnSorting.button", + "defString": "Sort fields", "highlighting": "string", "loc": { "start": { - "line": 37, - "column": 28, - "index": 1465 + "line": 66, + "column": 30, + "index": 2120 }, "end": { - "line": 40, - "column": 3, - "index": 1551 + "line": 69, + "column": 5, + "index": 2190 } }, - "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + "filepath": "src/components/datagrid/controls/column_sorting.tsx" }, { - "token": "euiCollapsibleNavButton.ariaLabelCollapse", - "defString": "Collapse navigation", + "token": "euiColumnSorting.sortFieldAriaLabel", + "defString": "Sort by: ", "highlighting": "string", "loc": { "start": { - "line": 41, - "column": 30, - "index": 1583 + "line": 70, + "column": 31, + "index": 2223 }, "end": { - "line": 44, - "column": 3, - "index": 1673 + "line": 73, + "column": 5, + "index": 2303 } }, - "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + "filepath": "src/components/datagrid/controls/column_sorting.tsx" }, { - "token": "euiCollapsibleNavButton.ariaLabelOpen", - "defString": "Open navigation", + "token": "euiColumnSorting.emptySorting", + "defString": "Currently no fields are sorted", "highlighting": "string", "loc": { "start": { - "line": 45, + "line": 220, + "column": 14, + "index": 6937 + }, + "end": { + "line": 223, + "column": 16, + "index": 7073 + } + }, + "filepath": "src/components/datagrid/controls/column_sorting.tsx" + }, + { + "token": "euiColumnSorting.pickFields", + "defString": "Pick fields to sort by", + "highlighting": "string", + "loc": { + "start": { + "line": 253, + "column": 24, + "index": 8304 + }, + "end": { + "line": 256, "column": 26, - "index": 1701 + "index": 8460 + } + }, + "filepath": "src/components/datagrid/controls/column_sorting.tsx" + }, + { + "token": "euiColumnSorting.clearAll", + "defString": "Clear sorting", + "highlighting": "string", + "loc": { + "start": { + "line": 328, + "column": 20, + "index": 11814 }, "end": { - "line": 48, + "line": 331, + "column": 22, + "index": 11947 + } + }, + "filepath": "src/components/datagrid/controls/column_sorting.tsx" + }, + { + "token": "euiColumnSelector.dragHandleAriaLabel", + "defString": "drag handle", + "highlighting": "string", + "loc": { + "start": { + "line": 164, + "column": 30, + "index": 5119 + }, + "end": { + "line": 167, "column": 3, - "index": 1783 + "index": 5197 } }, - "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + "filepath": "src/components/datagrid/controls/column_selector.tsx" }, { - "token": "euiCollapsibleNavButton.ariaLabelClose", - "defString": "Close navigation", + "token": "euiColumnSelector.button", + "defString": "Columns", "highlighting": "string", "loc": { "start": { - "line": 49, - "column": 27, - "index": 1812 + "line": 203, + "column": 12, + "index": 6382 + }, + "end": { + "line": 203, + "column": 74, + "index": 6444 + } + }, + "filepath": "src/components/datagrid/controls/column_selector.tsx" + }, + { + "token": "euiColumnSelector.search", + "defString": "Search", + "highlighting": "string", + "loc": { + "start": { + "line": 209, + "column": 12, + "index": 6590 + }, + "end": { + "line": 215, + "column": 13, + "index": 6802 + } + }, + "filepath": "src/components/datagrid/controls/column_selector.tsx" + }, + { + "token": "euiColumnSelector.searchcolumns", + "defString": "Search columns", + "highlighting": "string", + "loc": { + "start": { + "line": 209, + "column": 12, + "index": 6590 }, "end": { - "line": 52, - "column": 3, - "index": 1896 + "line": 215, + "column": 13, + "index": 6802 } }, - "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + "filepath": "src/components/datagrid/controls/column_selector.tsx" }, { - "token": "euiCollapsibleNavKibanaSolution.switcherTitle", - "defString": "Solution view", + "token": "euiColumnSelector.selectAll", + "defString": "Show all", "highlighting": "string", "loc": { "start": { - "line": 73, - "column": 40, - "index": 2535 + "line": 340, + "column": 18, + "index": 12118 }, "end": { - "line": 76, - "column": 3, - "index": 2623 + "line": 343, + "column": 20, + "index": 12242 } }, - "filepath": "src/components/collapsible_nav_beta/_kibana_solution/collapsible_nav_kibana_solution.tsx" + "filepath": "src/components/datagrid/controls/column_selector.tsx" }, { - "token": "euiCollapsibleNavKibanaSolution.switcherAriaLabel", - "defString": " - click to switch to another solution", + "token": "euiColumnSelector.hideAll", + "defString": "Hide all", "highlighting": "string", "loc": { "start": { - "line": 77, - "column": 44, - "index": 2669 + "line": 353, + "column": 18, + "index": 12602 }, "end": { - "line": 80, - "column": 3, - "index": 2786 + "line": 356, + "column": 20, + "index": 12724 } }, - "filepath": "src/components/collapsible_nav_beta/_kibana_solution/collapsible_nav_kibana_solution.tsx" + "filepath": "src/components/datagrid/controls/column_selector.tsx" }, { - "token": "euiCollapsibleNavKibanaSolution.groupLabel", - "defString": "Navigate to solution", + "token": "euiComboBoxPill.removeSelection", + "defString": "Remove {children} from selection in this group", "highlighting": "string", "loc": { "start": { - "line": 81, - "column": 37, - "index": 2825 + "line": 67, + "column": 6, + "index": 2037 }, "end": { - "line": 84, - "column": 3, - "index": 2917 + "line": 71, + "column": 7, + "index": 2196 } }, - "filepath": "src/components/collapsible_nav_beta/_kibana_solution/collapsible_nav_kibana_solution.tsx" + "filepath": "src/components/combo_box/combo_box_input/combo_box_pill.tsx" }, { "token": "euiComboBoxOptionsList.loadingOptions", @@ -6516,22 +6426,130 @@ "filepath": "src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx" }, { - "token": "euiComboBoxPill.removeSelection", - "defString": "Remove {children} from selection in this group", + "token": "euiCollapsibleNavButton.ariaLabelExpand", + "defString": "Expand navigation", "highlighting": "string", "loc": { "start": { - "line": 67, - "column": 6, - "index": 2037 + "line": 37, + "column": 28, + "index": 1465 }, "end": { - "line": 71, - "column": 7, - "index": 2196 + "line": 40, + "column": 3, + "index": 1551 } }, - "filepath": "src/components/combo_box/combo_box_input/combo_box_pill.tsx" + "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + }, + { + "token": "euiCollapsibleNavButton.ariaLabelCollapse", + "defString": "Collapse navigation", + "highlighting": "string", + "loc": { + "start": { + "line": 41, + "column": 30, + "index": 1583 + }, + "end": { + "line": 44, + "column": 3, + "index": 1673 + } + }, + "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + }, + { + "token": "euiCollapsibleNavButton.ariaLabelOpen", + "defString": "Open navigation", + "highlighting": "string", + "loc": { + "start": { + "line": 45, + "column": 26, + "index": 1701 + }, + "end": { + "line": 48, + "column": 3, + "index": 1783 + } + }, + "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + }, + { + "token": "euiCollapsibleNavButton.ariaLabelClose", + "defString": "Close navigation", + "highlighting": "string", + "loc": { + "start": { + "line": 49, + "column": 27, + "index": 1812 + }, + "end": { + "line": 52, + "column": 3, + "index": 1896 + } + }, + "filepath": "src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx" + }, + { + "token": "euiCollapsibleNavKibanaSolution.switcherTitle", + "defString": "Solution view", + "highlighting": "string", + "loc": { + "start": { + "line": 73, + "column": 40, + "index": 2535 + }, + "end": { + "line": 76, + "column": 3, + "index": 2623 + } + }, + "filepath": "src/components/collapsible_nav_beta/_kibana_solution/collapsible_nav_kibana_solution.tsx" + }, + { + "token": "euiCollapsibleNavKibanaSolution.switcherAriaLabel", + "defString": " - click to switch to another solution", + "highlighting": "string", + "loc": { + "start": { + "line": 77, + "column": 44, + "index": 2669 + }, + "end": { + "line": 80, + "column": 3, + "index": 2786 + } + }, + "filepath": "src/components/collapsible_nav_beta/_kibana_solution/collapsible_nav_kibana_solution.tsx" + }, + { + "token": "euiCollapsibleNavKibanaSolution.groupLabel", + "defString": "Navigate to solution", + "highlighting": "string", + "loc": { + "start": { + "line": 81, + "column": 37, + "index": 2825 + }, + "end": { + "line": 84, + "column": 3, + "index": 2917 + } + }, + "filepath": "src/components/collapsible_nav_beta/_kibana_solution/collapsible_nav_kibana_solution.tsx" }, { "token": "euiCardSelect.selected", @@ -7038,76 +7056,76 @@ "filepath": "src/components/date_picker/super_date_picker/date_popover/date_popover_content.tsx" }, { - "token": "euiAbsoluteTab.dateFormatButtonLabel", - "defString": "Parse date", + "token": "euiDatePopoverButton.invalidTitle", + "defString": "Invalid date: {title}", "highlighting": "string", "loc": { "start": { - "line": 95, - "column": 28, - "index": 2852 + "line": 107, + "column": 23, + "index": 3054 }, "end": { - "line": 98, + "line": 111, "column": 3, - "index": 2928 + "index": 3153 } }, - "filepath": "src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx" + "filepath": "src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx" }, { - "token": "euiAbsoluteTab.dateFormatError", - "defString": "Allowed formats: {dateFormat}, ISO 8601, RFC 2822, or Unix timestamp.", + "token": "euiDatePopoverButton.outdatedTitle", + "defString": "Update needed: {title}", "highlighting": "string", "loc": { "start": { - "line": 99, - "column": 26, - "index": 2956 + "line": 112, + "column": 24, + "index": 3179 }, "end": { - "line": 103, + "line": 116, "column": 3, - "index": 3138 + "index": 3280 } }, - "filepath": "src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx" + "filepath": "src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx" }, { - "token": "euiDatePopoverButton.invalidTitle", - "defString": "Invalid date: {title}", + "token": "euiAbsoluteTab.dateFormatButtonLabel", + "defString": "Parse date", "highlighting": "string", "loc": { "start": { - "line": 107, - "column": 23, - "index": 3054 + "line": 95, + "column": 28, + "index": 2852 }, "end": { - "line": 111, + "line": 98, "column": 3, - "index": 3153 + "index": 2928 } }, - "filepath": "src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx" + "filepath": "src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx" }, { - "token": "euiDatePopoverButton.outdatedTitle", - "defString": "Update needed: {title}", + "token": "euiAbsoluteTab.dateFormatError", + "defString": "Allowed formats: {dateFormat}, ISO 8601, RFC 2822, or Unix timestamp.", "highlighting": "string", "loc": { "start": { - "line": 112, - "column": 24, - "index": 3179 + "line": 99, + "column": 26, + "index": 2956 }, "end": { - "line": 116, + "line": 103, "column": 3, - "index": 3280 + "index": 3138 } }, - "filepath": "src/components/date_picker/super_date_picker/date_popover/date_popover_button.tsx" + "filepath": "src/components/date_picker/super_date_picker/date_popover/absolute_tab.tsx" }, { "token": "euiDataGridHeaderCell.sortedByAscendingSingle", diff --git a/packages/eui/i18ntokens_changelog.json b/packages/eui/i18ntokens_changelog.json index 4419f233cc4e..dc17f0dd8d12 100644 --- a/packages/eui/i18ntokens_changelog.json +++ b/packages/eui/i18ntokens_changelog.json @@ -1,4 +1,14 @@ [ + { + "version": "113.3.0", + "changes": [ + { + "token": "euiColorPicker.ariaLabel", + "changeType": "added", + "value": "Select a color" + } + ] + }, { "version": "113.1.0", "changes": [ diff --git a/packages/eui/package.json b/packages/eui/package.json index 885e347f8589..a86a4a2ef84a 100644 --- a/packages/eui/package.json +++ b/packages/eui/package.json @@ -1,7 +1,7 @@ { "name": "@elastic/eui", "description": "Elastic UI Component Library", - "version": "113.3.0", + "version": "114.0.0", "license": "SEE LICENSE IN LICENSE.txt", "main": "lib", "module": "es", @@ -108,6 +108,7 @@ "@cypress/webpack-dev-server": "^1.7.0", "@elastic/charts": "^64.1.0", "@elastic/datemath": "^5.0.3", + "@elastic/eslint-plugin-eui": "workspace:*", "@elastic/eui-theme-borealis": "workspace:*", "@emotion/babel-preset-css-prop": "^11.11.0", "@emotion/cache": "^11.11.0", @@ -256,7 +257,7 @@ }, "peerDependencies": { "@elastic/datemath": "^5.0.2", - "@elastic/eui-theme-borealis": "6.2.0", + "@elastic/eui-theme-borealis": "7.0.0", "@emotion/css": "11.x", "@emotion/react": "11.x", "@types/react": "^17.0 || ^18.0", diff --git a/packages/eui/src/components/basic_table/collapsed_item_actions.tsx b/packages/eui/src/components/basic_table/collapsed_item_actions.tsx index dea083d97430..cb2d2225c52b 100644 --- a/packages/eui/src/components/basic_table/collapsed_item_actions.tsx +++ b/packages/eui/src/components/basic_table/collapsed_item_actions.tsx @@ -115,7 +115,6 @@ export const CollapsedItemActions = ({ }} toolTipContent={toolTipContent} toolTipProps={{ - delay: 'long', // Avoid screen-readers announcing the same text twice disableScreenReaderOutput: typeof buttonContent === 'string' && @@ -148,7 +147,7 @@ export const CollapsedItemActions = ({ ); const withTooltip = !actionsDisabled && ( - + {popoverButton} ); diff --git a/packages/eui/src/components/basic_table/default_item_action.tsx b/packages/eui/src/components/basic_table/default_item_action.tsx index 4b032dcef6ee..16733202b239 100644 --- a/packages/eui/src/components/basic_table/default_item_action.tsx +++ b/packages/eui/src/components/basic_table/default_item_action.tsx @@ -60,7 +60,6 @@ export const DefaultItemAction = ({ const tooltipContent = callWithItemIfFunction(item)(action.description); const tooltipProps: Omit = { content: tooltipContent, - delay: 'long', // Avoid screen-readers announcing the same text twice disableScreenReaderOutput: typeof actionContent === 'string' && actionContent === tooltipContent, diff --git a/packages/eui/src/components/basic_table/table_types.ts b/packages/eui/src/components/basic_table/table_types.ts index 016c145486ad..da6b04cd7d5c 100644 --- a/packages/eui/src/components/basic_table/table_types.ts +++ b/packages/eui/src/components/basic_table/table_types.ts @@ -46,8 +46,7 @@ export type EuiTableColumnNameTooltipProps = { /** Additional props for EuiIcon */ iconProps?: EuiIconTipProps['iconProps']; /** Additional props for the EuiToolip */ - tooltipProps?: Omit & { - delay?: EuiToolTipProps['delay']; + tooltipProps?: Omit & { position?: EuiToolTipProps['position']; }; }; diff --git a/packages/eui/src/components/button/button_group/button_group.stories.tsx b/packages/eui/src/components/button/button_group/button_group.stories.tsx index 680dcd98aafd..3ca2d42eba52 100644 --- a/packages/eui/src/components/button/button_group/button_group.stories.tsx +++ b/packages/eui/src/components/button/button_group/button_group.stories.tsx @@ -12,7 +12,6 @@ import { disableStorybookControls } from '../../../../.storybook/utils'; import { LOKI_SELECTORS } from '../../../../.storybook/loki'; import { EuiSpacer } from '../../spacer'; -import { ToolTipDelay } from '../../tool_tip/tool_tip'; import { EuiButtonGroup, EuiButtonGroupProps, @@ -145,18 +144,14 @@ export const WithTooltips: Story = { label: 'Standard tooltip', toolTipContent: 'Hello world', autoFocus: true, // dev-only usage to showcase tooltip on load - toolTipProps: { - delay: 'none' as ToolTipDelay, // passing a (not-yet) supported value to hackishly force a lower delay for VRT - }, } as EuiButtonGroupOptionProps, { id: 'customToolTipProps', iconType: 'securitySignalDetected', label: 'Custom tooltip', - toolTipContent: 'Custom tooltip position and delay', + toolTipContent: 'Custom tooltip position', toolTipProps: { position: 'right', - delay: 'long', title: 'Hello world', }, // Consumers could also opt to hide titles if preferred diff --git a/packages/eui/src/components/button/split_button/split_button.stories.tsx b/packages/eui/src/components/button/split_button/split_button.stories.tsx index f60e689a1e4e..433498591334 100644 --- a/packages/eui/src/components/button/split_button/split_button.stories.tsx +++ b/packages/eui/src/components/button/split_button/split_button.stories.tsx @@ -16,7 +16,6 @@ import { EuiSpacer } from '../../spacer'; import { EuiFlexGroup } from '../../flex'; import { EuiWrappingPopover } from '../../popover'; import { EuiContextMenu } from '../../context_menu'; -import { ToolTipDelay } from '../../tool_tip/tool_tip'; import { EuiSplitButton, EuiSplitButtonProps } from './split_button'; const decorators: Meta['decorators'] = [ @@ -90,7 +89,6 @@ export const WithTooltip: Story = { aria-label="Secondary action" tooltipProps={{ content: 'Tooltip content', - delay: 'none' as ToolTipDelay, // passing a not (yet) supported value to hackishly force a lower delay for VRT }} autoFocus={true} // VRT-only workaround to ensure an opened tooltip />, diff --git a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap index c37dbf7fdedb..f93593dd4f69 100644 --- a/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap +++ b/packages/eui/src/components/collapsible_nav_beta/collapsible_nav_item/collapsed/__snapshots__/collapsed_nav_button.test.tsx.snap @@ -34,7 +34,6 @@ exports[`EuiCollapsedNavButton renders a tooltip around the icon button 1`] = ` class="euiToolTipPopover euiToolTip emotion-euiToolTip-left-euiCollapsedNavItemTooltip-left" data-position="left" id="generated-id" - position="left" role="tooltip" style="top: -10px; left: -16px;" > diff --git a/packages/eui/src/components/color_picker/__snapshots__/color_picker.test.tsx.snap b/packages/eui/src/components/color_picker/__snapshots__/color_picker.test.tsx.snap index 3d4ffc901d90..1a18fb64effb 100644 --- a/packages/eui/src/components/color_picker/__snapshots__/color_picker.test.tsx.snap +++ b/packages/eui/src/components/color_picker/__snapshots__/color_picker.test.tsx.snap @@ -26,7 +26,8 @@ exports[`EuiColorPicker color empty string 1`] = ` + + + + Press the down key to open a popover containing color options + +
@@ -78,7 +95,8 @@ exports[`EuiColorPicker color null 1`] = `
+ + + + Press the down key to open a popover containing color options + +
@@ -130,13 +164,30 @@ exports[`EuiColorPicker color valid string 1`] = `
+ + + + Press the down key to open a popover containing color options + +
@@ -181,13 +232,30 @@ exports[`EuiColorPicker compressed 1`] = `
+ + + + Press the down key to open a popover containing color options + +
@@ -232,7 +300,8 @@ exports[`EuiColorPicker disabled 1`] = `
+ + + + Press the down key to open a popover containing color options + + @@ -271,13 +356,30 @@ exports[`EuiColorPicker fullWidth 1`] = ` + + + + Press the down key to open a popover containing color options + +
@@ -531,13 +633,30 @@ exports[`EuiColorPicker isClearable 1`] = `
+ + + + Press the down key to open a popover containing color options + +
@@ -592,7 +711,8 @@ exports[`EuiColorPicker placeholder 1`] = `
+ + + + Press the down key to open a popover containing color options + +
@@ -661,13 +797,30 @@ exports[`EuiColorPicker prepend and append 1`] = `
+ + + + Press the down key to open a popover containing color options + +
@@ -729,7 +882,8 @@ exports[`EuiColorPicker readOnly 1`] = `
+ + + + Press the down key to open a popover containing color options + + @@ -768,13 +938,30 @@ exports[`EuiColorPicker renders 1`] = ` + + + + Press the down key to open a popover containing color options + +
@@ -819,13 +1006,30 @@ exports[`EuiColorPicker showAlpha 1`] = `
+ + + + Press the down key to open a popover containing color options + +
diff --git a/packages/eui/src/components/color_picker/color_picker.stories.tsx b/packages/eui/src/components/color_picker/color_picker.stories.tsx index e7349adc7ecb..3d1fe847c3ae 100644 --- a/packages/eui/src/components/color_picker/color_picker.stories.tsx +++ b/packages/eui/src/components/color_picker/color_picker.stories.tsx @@ -10,7 +10,8 @@ import React, { FunctionComponent, useState, useEffect } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { enableFunctionToggleControls } from '../../../.storybook/utils'; -import { euiPaletteColorBlind } from '../..//services'; +import { euiPaletteColorBlind } from '../../services'; +import { EuiFormRow } from '../form'; import { EuiColorPicker, EuiColorPickerProps } from './color_picker'; const meta: Meta = { @@ -45,6 +46,22 @@ export const Playground: Story = { render: (args) => , }; +export const InFormRow: Story = { + name: 'In FormRow', + parameters: { + loki: { + // The visual composition of label + select is tested by form controls separately + skip: true, + }, + }, + ...Playground, + render: (args) => ( + + + + ), +}; + export const InlineWithAllElements: Story = { tags: ['vrt-only'], args: { diff --git a/packages/eui/src/components/color_picker/color_picker.tsx b/packages/eui/src/components/color_picker/color_picker.tsx index e5babd1c12de..58868be0c5e9 100644 --- a/packages/eui/src/components/color_picker/color_picker.tsx +++ b/packages/eui/src/components/color_picker/color_picker.tsx @@ -23,6 +23,7 @@ import { useEuiMemoizedStyles, keys, useEuiPaletteColorBlind, + useGeneratedHtmlId, } from '../../services'; import { CommonProps } from '../common'; import { @@ -209,6 +210,9 @@ export const EuiColorPicker: FunctionComponent = ({ isClearable = false, placeholder, 'data-test-subj': dataTestSubj, + 'aria-label': _ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, }) => { const [ popoverLabel, @@ -219,6 +223,7 @@ export const EuiColorPicker: FunctionComponent = ({ alphaLabel, openLabel, closeLabel, + ariaLabel, ] = useEuiI18n( [ 'euiColorPicker.popoverLabel', @@ -229,6 +234,7 @@ export const EuiColorPicker: FunctionComponent = ({ 'euiColorPicker.alphaLabel', 'euiColorPicker.openLabel', 'euiColorPicker.closeLabel', + 'euiColorPicker.ariaLabel', ], [ 'Color selection dialog', @@ -239,9 +245,19 @@ export const EuiColorPicker: FunctionComponent = ({ 'Alpha channel (opacity) value', 'Press the escape key to close the popover', 'Press the down key to open a popover containing color options', + 'Select a color', ] ); + const openLabelId = useGeneratedHtmlId({ + prefix: 'colorPicker', + suffix: 'openLabel', + }); + const closeLabelId = useGeneratedHtmlId({ + prefix: 'colorPicker', + suffix: 'closeLabel', + }); + const defaultSwatches = useEuiPaletteColorBlind(); const swatches = _swatches ?? defaultSwatches; @@ -621,9 +637,35 @@ export const EuiColorPicker: FunctionComponent = ({ fullWidth={fullWidth} autoComplete="off" data-test-subj={testSubjAnchor} - aria-label={isColorSelectorShown ? openLabel : closeLabel} + // if an id is provided it might be used in combination with `htmlFor` on a label, + // so we don't want to override it with a fallback `aria-label` + aria-label={ + _ariaLabel + ? _ariaLabel + : id || ariaLabelledby + ? undefined + : ariaLabel + } + aria-labelledby={ariaLabelledby} + aria-describedby={classNames( + isColorSelectorShown ? openLabelId : closeLabelId, + ariaDescribedby + )} controlOnly // Don't need two EuiFormControlwrappers /> + + + + {/* Separate hint messages that are toggled on the id work more + reliably to prevent stale messages in VO/Safari */} + + {openLabel} + + + {closeLabel} + + + ); } diff --git a/packages/eui/src/components/color_picker/color_picker_swatch.tsx b/packages/eui/src/components/color_picker/color_picker_swatch.tsx index d4f43b65eb15..2ec606cb2095 100644 --- a/packages/eui/src/components/color_picker/color_picker_swatch.tsx +++ b/packages/eui/src/components/color_picker/color_picker_swatch.tsx @@ -28,8 +28,7 @@ export type EuiColorPickerSwatchProps = CommonProps & showToolTip?: boolean; /** Additional props for the EuiToolip when `showToolTip={true}` */ - toolTipProps?: Omit & { - delay?: EuiToolTipProps['delay']; + toolTipProps?: Omit & { position?: EuiToolTipProps['position']; }; }; diff --git a/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap b/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap index a68d93a36b1b..7ab793031cd4 100644 --- a/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap +++ b/packages/eui/src/components/context_menu/__snapshots__/context_menu_item.test.tsx.snap @@ -117,7 +117,6 @@ exports[`EuiContextMenuItem tooltip behavior 1`] = ` class="euiToolTipPopover euiToolTip emotion-euiToolTip-top" data-position="top" id="generated-id" - position="top" role="tooltip" style="top: -16px; left: -10px;" > diff --git a/packages/eui/src/components/context_menu/context_menu_item.test.tsx b/packages/eui/src/components/context_menu/context_menu_item.test.tsx index 5ca21f6a87d1..a13e3a6835e8 100644 --- a/packages/eui/src/components/context_menu/context_menu_item.test.tsx +++ b/packages/eui/src/components/context_menu/context_menu_item.test.tsx @@ -158,7 +158,7 @@ describe('EuiContextMenuItem', () => { const { getByRole, baseElement } = render( Hello diff --git a/packages/eui/src/components/datagrid/controls/display_selector.tsx b/packages/eui/src/components/datagrid/controls/display_selector.tsx index a95dd1b8edc7..4b4414460f4d 100644 --- a/packages/eui/src/components/datagrid/controls/display_selector.tsx +++ b/packages/eui/src/components/datagrid/controls/display_selector.tsx @@ -425,7 +425,7 @@ export const useDataGridDisplaySelector = ( panelProps={{ css: logicalStyle('width', popoverWidth) }} panelClassName="euiDataGrid__displayPopoverPanel" button={ - + + { const { scrollCellIntoView } = useScrollCellIntoView(args); const { focusedCell } = useContext(DataGridFocusContext); - const isPointerDown = useIsPointerDown(args.outerGridRef); + const isPointerDownRef = useIsPointerDown(args.outerGridRef); + + /** + * Set when `focusedCell` changes while the pointer is held down (e.g. clicking a cell). + * Allows the `pointerup` listener below to scroll on release without + * causing snap-back when the user scrolls the grid without changing focus. + */ + const pendingScrollRef = useRef(false); useEffect(() => { - if (focusedCell) { - // do not scroll if text is being selected - if (isPointerDown || window?.getSelection()?.type === 'Range') { - return; - } + if (!focusedCell) return; + if (isPointerDownRef.current) { + // Pointer is down - defer scroll decision to the pointerup listener + pendingScrollRef.current = true; + return; + } + + scrollCellIntoView({ rowIndex: focusedCell[1], colIndex: focusedCell[0] }); + }, [focusedCell, scrollCellIntoView, isPointerDownRef]); + + useEffect(() => { + const handlePointerUp = () => { + if (!pendingScrollRef.current || !focusedCell) return; + + pendingScrollRef.current = false; + + // Skip if the interaction resulted in text being selected + if (window?.getSelection()?.type === 'Range') return; scrollCellIntoView({ rowIndex: focusedCell[1], colIndex: focusedCell[0], }); - } - }, [focusedCell, isPointerDown, scrollCellIntoView]); + }; + + document.addEventListener('pointerup', handlePointerUp, { capture: true }); + + return () => + document.removeEventListener('pointerup', handlePointerUp, { + capture: true, + }); + }, [focusedCell, scrollCellIntoView]); const { popoverIsOpen, cellLocation } = useContext( DataGridCellPopoverContext diff --git a/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx index 66b331bb0946..9e4157aaef09 100644 --- a/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx +++ b/packages/eui/src/components/date_picker/super_date_picker/time_window_buttons.test.tsx @@ -477,7 +477,7 @@ describe('EuiTimeWindowButtons', () => { const start = 'not a date'; const end = 'now'; - const { getByTestSubject, findByText } = render( + const { debug, getByTestSubject, findByText } = render( ); @@ -489,13 +489,13 @@ describe('EuiTimeWindowButtons', () => { expect(apply).not.toHaveBeenCalled(); - act(() => { - fireEvent.mouseEnter(getByTestSubject('timeWindowButtonsZoomOut')); - }); + fireEvent.mouseOver(getByTestSubject('timeWindowButtonsZoomOut')); - expect( - await findByText('Cannot zoom out invalid time window') - ).toBeInTheDocument(); + debug(); + + const tooltip = await findByText('Cannot zoom out invalid time window'); + + expect(tooltip).toBeInTheDocument(); }); it('hides zoom in button by default', () => { diff --git a/packages/eui/src/components/filter_group/filter_select_item.tsx b/packages/eui/src/components/filter_group/filter_select_item.tsx index a93b10837290..11f5639fa186 100644 --- a/packages/eui/src/components/filter_group/filter_select_item.tsx +++ b/packages/eui/src/components/filter_group/filter_select_item.tsx @@ -14,6 +14,7 @@ import { CommonProps } from '../common'; import { EuiFlexGroup, EuiFlexItem } from '../flex'; import { EuiToolTip } from '../tool_tip'; +import type { EuiToolTipRef } from '../tool_tip'; import { EuiIcon } from '../icon'; import { EuiComboBoxOptionOption } from '../combo_box'; @@ -62,7 +63,7 @@ export class EuiFilterSelectItemClass extends Component< }; buttonRef: HTMLButtonElement | null = null; - tooltipRef = createRef(); + tooltipRef = createRef(); state = { hasFocus: false, diff --git a/packages/eui/src/components/flyout/_flyout_overlay.tsx b/packages/eui/src/components/flyout/_flyout_overlay.tsx index dc2fd21d2945..76ae36210de8 100644 --- a/packages/eui/src/components/flyout/_flyout_overlay.tsx +++ b/packages/eui/src/components/flyout/_flyout_overlay.tsx @@ -6,8 +6,9 @@ * Side Public License, v 1. */ -import React, { PropsWithChildren, useMemo } from 'react'; +import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { css, cx } from '@emotion/css'; +import { useCombinedRefs } from '../../services'; import { EuiOverlayMask } from '../overlay_mask'; import { EuiPortal } from '../portal'; import type { EuiFlyoutComponentProps } from './flyout.component'; @@ -22,14 +23,18 @@ export interface EuiFlyoutOverlayProps extends PropsWithChildren { * z-index); 'below' to keep them in the flyout stacking level. */ headerZindexLocation?: 'above' | 'below'; + /** + * When provided, clips the overlay mask to the container's bounding rect + * rather than covering the full viewport. + */ + containerRect?: DOMRect | null; } const getEuiFlyoutOverlayStyles = (zIndex: number) => { /* This needs to have !important to override the default EuiOverlayMask z-index based on the headerZindexLocation prop. Using the style attribute - doesn't work since EuiOverlayMask requires a string style prop that - causes React errors in the test environment. + doesn't work since EuiOverlayMask requires the styles to be provided via className */ return css` z-index: ${zIndex} !important; @@ -51,12 +56,44 @@ export const EuiFlyoutOverlay = ({ hasOverlayMask, maskZIndex, headerZindexLocation = 'below', + containerRect, }: EuiFlyoutOverlayProps) => { const styles = useMemo( () => getEuiFlyoutOverlayStyles(maskZIndex), [maskZIndex] ); + // Internal ref so we can apply containerRect positioning directly on the DOM + // node, avoiding new Emotion CSS class generation on every scroll/resize. + const internalMaskRef = useRef(null); + const combinedMaskRef = useCombinedRefs([ + internalMaskRef, + maskProps?.maskRef, + ]); + + useEffect(() => { + const node = internalMaskRef.current; + if (!node) return; + + // containerRect positioning must be applied via node.style.setProperty rather than + // through the style prop - EuiOverlayMask requires styles to be passed via className + if (containerRect) { + node.style.setProperty('inset-block-start', `${containerRect.top}px`); + node.style.setProperty('inset-inline-start', `${containerRect.left}px`); + node.style.setProperty('inline-size', `${containerRect.width}px`); + node.style.setProperty('block-size', `${containerRect.height}px`); + node.style.setProperty('inset-inline-end', 'auto'); + node.style.setProperty('inset-block-end', 'auto'); + } else { + node.style.removeProperty('inset-block-start'); + node.style.removeProperty('inset-inline-start'); + node.style.removeProperty('inline-size'); + node.style.removeProperty('block-size'); + node.style.removeProperty('inset-inline-end'); + node.style.removeProperty('inset-block-end'); + } + }, [containerRect, hasOverlayMask]); // toggling ownFocus while the flyout is already open should cause re-render + let content = children; if (!isPushed || hasOverlayMask) { @@ -73,6 +110,7 @@ export const EuiFlyoutOverlay = ({ maskProps?.headerZindexLocation ?? headerZindexLocation } {...maskProps} + maskRef={combinedMaskRef} className={classes} /> )} diff --git a/packages/eui/src/components/flyout/flyout.component.tsx b/packages/eui/src/components/flyout/flyout.component.tsx index 53c87aed218a..d9ee36f5cd05 100644 --- a/packages/eui/src/components/flyout/flyout.component.tsx +++ b/packages/eui/src/components/flyout/flyout.component.tsx @@ -1075,6 +1075,7 @@ export const EuiFlyoutComponent = forwardRef( isPushed={isPushed} maskZIndex={maskZIndex} headerZindexLocation={effectiveHeaderZindexLocation} + containerRect={containerRect} maskProps={{ ...maskProps, maskRef: maskCombinedRefs, diff --git a/packages/eui/src/components/flyout/flyout.test.tsx b/packages/eui/src/components/flyout/flyout.test.tsx index 9d8c5c133d57..abcf449ec464 100644 --- a/packages/eui/src/components/flyout/flyout.test.tsx +++ b/packages/eui/src/components/flyout/flyout.test.tsx @@ -919,6 +919,27 @@ describe('EuiFlyout', () => { const childFlyout = getByTestSubject('child-flyout'); expect(childFlyout).not.toHaveAttribute('data-managed-flyout-level'); }); + + it('accepts historyKey prop with session="start" and renders without error', () => { + const sharedKey = Symbol(); + const { getByRole } = render( + + {}} + flyoutMenuProps={{ title: 'Main Flyout' }} + aria-label="Test flyout" + > + Content + + + ); + const dialog = getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-label', 'Test flyout'); + expect(dialog).toHaveAttribute('data-managed-flyout-level', 'main'); + }); }); describe('ref forwarding', () => { diff --git a/packages/eui/src/components/flyout/flyout.tsx b/packages/eui/src/components/flyout/flyout.tsx index 647a8d799b58..225dea503d17 100644 --- a/packages/eui/src/components/flyout/flyout.tsx +++ b/packages/eui/src/components/flyout/flyout.tsx @@ -46,7 +46,7 @@ export type EuiFlyoutProps = Omit< * When the `session` prop is undefined (not set), the flyout will automatically inherit from * a parent flyout if it's nested inside one. Otherwise, it defaults to `never`. * - * Check out [EuiFlyout session management](https://eui.elastic.co/docs/components/containers/flyout/session-management) + * Check out [EuiFlyout session management](https://eui.elastic.co/docs/components/containers/flyout/#flyout-session-management) * documentation to learn more. * @default undefined (auto-inherit when nested, otherwise 'never') */ @@ -54,6 +54,11 @@ export type EuiFlyoutProps = Omit< | typeof SESSION_START | typeof SESSION_INHERIT | typeof SESSION_NEVER; + /** + * Optional Symbol to scope flyout history. Only flyouts that receive the same Symbol reference share Back button and history; omit to get a unique group per session. + * @default undefined (each session gets a unique key and does not share history) + */ + historyKey?: symbol; /** * Callback fired when the flyout becomes active/visible, which may happen programmatically from history navigation. */ @@ -68,7 +73,7 @@ export const EuiFlyout = forwardRef< HTMLDivElement | HTMLElement, EuiFlyoutProps<'div' | 'nav'> >((props, ref) => { - const { as, onClose, onActive, session, ...rest } = + const { as, onClose, onActive, session, historyKey, ...rest } = usePropsWithComponentDefaults('EuiFlyout', props); const hasActiveSession = useHasActiveSession(); const isInsideParentFlyout = useIsInsideParentFlyout(); @@ -101,6 +106,7 @@ export const EuiFlyout = forwardRef< return ( ({ + EuiFlyoutOverlay: (props: any) => { + mockOverlayProps(props); + return <>{props.children}; + }, +})); + +jest.mock('../portal', () => ({ + EuiPortal: ({ children }: { children: any }) => children, +})); + +describe('EuiFlyout container prop', () => { + const MOCK_RECT = { + top: 50, + left: 100, + width: 900, + height: 600, + right: 1000, + bottom: 650, + x: 100, + y: 50, + toJSON: () => ({}), + } as DOMRect; + + let containerEl: HTMLDivElement; + + beforeEach(() => { + mockOverlayProps.mockClear(); + containerEl = document.createElement('div'); + jest.spyOn(containerEl, 'getBoundingClientRect').mockReturnValue(MOCK_RECT); + document.body.appendChild(containerEl); + }); + + afterEach(() => { + containerEl.remove(); + }); + + it('passes containerRect to EuiFlyoutOverlay when container is provided', async () => { + render( {}} container={containerEl} />); + + // containerRect is set asynchronously via useLayoutEffect after + // useResizeObserver fires, so wait for the overlay to re-render + // with a non-null containerRect. + await waitFor(() => { + const lastCall = mockOverlayProps.mock.calls.at(-1)?.[0]; + expect(lastCall?.containerRect).toEqual( + expect.objectContaining({ + top: 50, + left: 100, + width: 900, + height: 600, + }) + ); + }); + }); + + it('does not pass containerRect when no container is provided', () => { + render( {}} />); + + const lastCall = mockOverlayProps.mock.calls.at(-1)?.[0]; + expect(lastCall?.containerRect).toBeNull(); + }); +}); diff --git a/packages/eui/src/components/flyout/manager/README.md b/packages/eui/src/components/flyout/manager/README.md index 3a4fed99c5df..2cd83e25a691 100644 --- a/packages/eui/src/components/flyout/manager/README.md +++ b/packages/eui/src/components/flyout/manager/README.md @@ -7,7 +7,16 @@ [Documentation - sources](../../../../../website/docs/components/containers/flyout/session-management.mdx) Session management for EuiFlyout is enabled with the `session` prop. You can read more about that -in the [main EuiFlyout developer README](../README.md). +in the [main EuiFlyout developer README](../README.md). + +## History scoping (`historyKey`) + +Flyout history (Back button and history popover) is scoped by an optional **`historyKey`** prop (type `symbol`). + +- **When `historyKey` is omitted**: Each session gets a unique key internally (`Symbol()`), so flyouts do not share history. The Back button will not appear for cross-session navigation. +- **When `historyKey` is set**: Only flyouts that receive the **same Symbol reference** share history. Use a shared Symbol (e.g. `const key = Symbol();` passed to multiple `EuiFlyout` instances) to group related flyouts so their Back button and history only show entries from that group. + +This allows different domains (e.g. different product areas) to use `session="start"` without mixing their histories. Child flyouts inherit the main flyout's key and do not pass their own. ## Components and hooks diff --git a/packages/eui/src/components/flyout/manager/__mocks__/index.ts b/packages/eui/src/components/flyout/manager/__mocks__/index.ts index e5e87974774b..bc4724c47979 100644 --- a/packages/eui/src/components/flyout/manager/__mocks__/index.ts +++ b/packages/eui/src/components/flyout/manager/__mocks__/index.ts @@ -88,6 +88,7 @@ export const createTestSession = ( title, childFlyoutId, childHistory: [], + historyKey: Symbol(mainFlyoutId), zIndex: 0, ...overrides, }); diff --git a/packages/eui/src/components/flyout/manager/actions.test.ts b/packages/eui/src/components/flyout/manager/actions.test.ts index d42e06d91a3e..3861db98f8aa 100644 --- a/packages/eui/src/components/flyout/manager/actions.test.ts +++ b/packages/eui/src/components/flyout/manager/actions.test.ts @@ -118,6 +118,7 @@ describe('flyout manager actions', () => { 'main', LEVEL_MAIN, 'm', + undefined, 'faceHappy' ); @@ -127,6 +128,7 @@ describe('flyout manager actions', () => { title: 'main', level: LEVEL_MAIN, size: 'm', + historyKey: undefined, iconType: 'faceHappy', }); }); @@ -137,6 +139,7 @@ describe('flyout manager actions', () => { 'main', LEVEL_MAIN, 'm', + undefined, 'faceHappy', 100 ); @@ -147,10 +150,33 @@ describe('flyout manager actions', () => { title: 'main', level: LEVEL_MAIN, size: 'm', + historyKey: undefined, iconType: 'faceHappy', minWidth: 100, }); }); + + it('should include historyKey in action when provided', () => { + const key = Symbol('test'); + const action = addFlyout( + 'flyout-1', + 'main', + LEVEL_MAIN, + 'm', + key, + 'faceHappy' + ); + + expect(action).toEqual({ + type: ACTION_ADD, + flyoutId: 'flyout-1', + title: 'main', + level: LEVEL_MAIN, + size: 'm', + historyKey: key, + iconType: 'faceHappy', + }); + }); }); describe('closeFlyout', () => { diff --git a/packages/eui/src/components/flyout/manager/actions.ts b/packages/eui/src/components/flyout/manager/actions.ts index 9f4768f6ad12..130d6014715d 100644 --- a/packages/eui/src/components/flyout/manager/actions.ts +++ b/packages/eui/src/components/flyout/manager/actions.ts @@ -61,6 +61,7 @@ export interface AddFlyoutAction extends BaseAction { title: string; level: EuiFlyoutLevel; size?: string; + historyKey?: symbol; iconType?: IconType; minWidth?: number; } @@ -166,6 +167,7 @@ export type Action = * - `title` is used for the flyout menu. * - `level` determines whether the flyout is `main` or `child`. * - Optional `size` is the named EUI size (e.g. `s`, `m`, `l`). + * - Optional `historyKey` (Symbol) scopes history; only flyouts with the same reference share Back/history. Omit for a unique group per session. * - Optional `iconType` is shown next to the session title in the history menu. */ export const addFlyout = ( @@ -173,6 +175,7 @@ export const addFlyout = ( title: string, level: EuiFlyoutLevel = LEVEL_MAIN, size?: string, + historyKey?: symbol, iconType?: IconType, minWidth?: number ): AddFlyoutAction => ({ @@ -181,6 +184,7 @@ export const addFlyout = ( title, level, size, + historyKey, iconType, minWidth, }); diff --git a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx index 9ccc022c364e..5fb44914eac8 100644 --- a/packages/eui/src/components/flyout/manager/activity_stage.test.tsx +++ b/packages/eui/src/components/flyout/manager/activity_stage.test.tsx @@ -50,7 +50,7 @@ const buildMockState = ({ }>, } = {}) => ({ layoutMode, - sessions: [{ mainFlyoutId, childFlyoutId }], + sessions: [{ mainFlyoutId, childFlyoutId, historyKey: Symbol() }], flyouts, }); @@ -93,13 +93,16 @@ describe('useFlyoutActivityStage', () => { const TestComponent = ({ flyoutId, level, + shouldAnimate, }: { flyoutId: string; level: 'main' | 'child'; + shouldAnimate?: boolean; }) => { const { activityStage, onAnimationEnd } = useFlyoutActivityStage({ flyoutId, level, + shouldAnimate, }); return ( @@ -175,7 +178,7 @@ describe('useFlyoutActivityStage', () => { }); describe('stage transitions based on activity', () => { - it('transitions from ACTIVE to CLOSING when flyout becomes inactive', () => { + it('when shouldAnimate is false (default), transitions directly to final stage: ACTIVE to INACTIVE', () => { let currentMockState = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'main-1', @@ -197,12 +200,10 @@ describe('useFlyoutActivityStage', () => { ); - // Initially active expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( STAGE_ACTIVE ); - // Change to inactive - session no longer contains main-1 currentMockState = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'other-main', @@ -217,12 +218,55 @@ describe('useFlyoutActivityStage', () => { }); rerender(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_INACTIVE) + ); + }); + + it('when shouldAnimate is true, transitions to intermediate CLOSING when flyout becomes inactive', () => { + let currentMockState = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'main-1', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_ACTIVE, + }, + ], + }); + mockUseFlyoutManager.mockImplementation(() => ({ + state: currentMockState, + dispatch: mockDispatch, + })); + + const { rerender } = render( + + ); + + currentMockState = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'other-main', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_ACTIVE, + }, + ], + }); + rerender( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_CLOSING) ); }); - it('transitions from INACTIVE to RETURNING when flyout becomes active', () => { + it('when shouldAnimate is false (default), transitions directly: INACTIVE to ACTIVE', () => { const stateWithInactive = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'other-main', @@ -245,12 +289,10 @@ describe('useFlyoutActivityStage', () => { ); - // Initially inactive expect(screen.getByTestSubject('activity-stage')).toHaveTextContent( STAGE_INACTIVE ); - // Change to active - session now contains main-1 currentMockState = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'main-1', @@ -265,6 +307,50 @@ describe('useFlyoutActivityStage', () => { }); rerender(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_ACTIVE) + ); + }); + + it('when shouldAnimate is true, transitions to intermediate RETURNING when flyout becomes active', () => { + const stateWithInactive = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'other-main', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_INACTIVE, + }, + ], + }); + let currentMockState = stateWithInactive; + mockUseFlyoutManager.mockImplementation(() => ({ + state: currentMockState, + dispatch: mockDispatch, + })); + + const { rerender } = render( + + ); + + currentMockState = buildMockState({ + layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, + mainFlyoutId: 'main-1', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_INACTIVE, + }, + ], + }); + rerender( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_RETURNING) ); @@ -272,7 +358,7 @@ describe('useFlyoutActivityStage', () => { }); describe('main flyout backgrounding logic', () => { - it('transitions to BACKGROUNDING when main flyout is active, has child, and layout is stacked', () => { + it('when shouldAnimate is false (default), transitions directly to BACKGROUNDED when main has child and layout is stacked', () => { const stateWithChild = buildMockState({ layoutMode: LAYOUT_MODE_STACKED, mainFlyoutId: 'main-1', @@ -286,6 +372,27 @@ describe('useFlyoutActivityStage', () => { render(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_BACKGROUNDED) + ); + }); + + it('when shouldAnimate is true, transitions to BACKGROUNDING when main has child and layout is stacked', () => { + const stateWithChild = buildMockState({ + layoutMode: LAYOUT_MODE_STACKED, + mainFlyoutId: 'main-1', + childFlyoutId: 'child-1', + flyouts: defaultFlyouts, + }); + mockUseFlyoutManager.mockReturnValue({ + state: stateWithChild, + dispatch: mockDispatch, + }); + + render( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_BACKGROUNDING) ); @@ -350,7 +457,7 @@ describe('useFlyoutActivityStage', () => { }); describe('main flyout returning logic', () => { - it('transitions from BACKGROUNDED to RETURNING when child is gone', () => { + it('when shouldAnimate is false (default), transitions directly from BACKGROUNDED to ACTIVE when child is gone', () => { const stateWithBackgrounded = buildMockState({ layoutMode: LAYOUT_MODE_STACKED, mainFlyoutId: 'main-1', @@ -371,11 +478,11 @@ describe('useFlyoutActivityStage', () => { render(); expect(mockDispatch).toHaveBeenCalledWith( - mockSetActivityStage('main-1', STAGE_RETURNING) + mockSetActivityStage('main-1', STAGE_ACTIVE) ); }); - it('transitions from BACKGROUNDING to RETURNING when child is gone', () => { + it('when shouldAnimate is false (default), transitions directly from BACKGROUNDING to ACTIVE when child is gone', () => { const stateWithBackgrounding = buildMockState({ layoutMode: LAYOUT_MODE_STACKED, mainFlyoutId: 'main-1', @@ -395,12 +502,39 @@ describe('useFlyoutActivityStage', () => { render(); + expect(mockDispatch).toHaveBeenCalledWith( + mockSetActivityStage('main-1', STAGE_ACTIVE) + ); + }); + + it('when shouldAnimate is true, transitions from BACKGROUNDED to RETURNING when child is gone', () => { + const stateWithBackgrounded = buildMockState({ + layoutMode: LAYOUT_MODE_STACKED, + mainFlyoutId: 'main-1', + childFlyoutId: null, + flyouts: [ + { + flyoutId: 'main-1', + level: LEVEL_MAIN, + activityStage: STAGE_BACKGROUNDED, + }, + ], + }); + mockUseFlyoutManager.mockReturnValue({ + state: stateWithBackgrounded, + dispatch: mockDispatch, + }); + + render( + + ); + expect(mockDispatch).toHaveBeenCalledWith( mockSetActivityStage('main-1', STAGE_RETURNING) ); }); - it('transitions from BACKGROUNDED to RETURNING when layout changes to side-by-side', () => { + it('when shouldAnimate is false (default), transitions directly from BACKGROUNDED to ACTIVE when layout is side-by-side', () => { const stateWithBackgrounded = buildMockState({ layoutMode: LAYOUT_MODE_SIDE_BY_SIDE, mainFlyoutId: 'main-1', @@ -421,7 +555,7 @@ describe('useFlyoutActivityStage', () => { render(); expect(mockDispatch).toHaveBeenCalledWith( - mockSetActivityStage('main-1', STAGE_RETURNING) + mockSetActivityStage('main-1', STAGE_ACTIVE) ); }); diff --git a/packages/eui/src/components/flyout/manager/activity_stage.ts b/packages/eui/src/components/flyout/manager/activity_stage.ts index 404916a14f7a..8407dc0482d2 100644 --- a/packages/eui/src/components/flyout/manager/activity_stage.ts +++ b/packages/eui/src/components/flyout/manager/activity_stage.ts @@ -26,13 +26,41 @@ import { useFlyoutManager } from './provider'; export interface UseFlyoutActivityStageParams { flyoutId: string; level: EuiFlyoutLevel; + /** When false, skip intermediate stages (CLOSING, RETURNING, BACKGROUNDING) and transition directly to final state. */ + shouldAnimate?: boolean; } export interface UseFlyoutActivityStageReturn { activityStage: EuiFlyoutActivityStage; + /** + * Pass to the flyout's `onAnimationEnd` prop to finalize transitional stages + * (e.g. CLOSING -> INACTIVE). When `shouldAnimate` is false, the intermediate + * CLOSING/RETURNING/BACKGROUNDING stages are skipped, but OPENING -> ACTIVE + * still relies on this handler since new flyouts always start in OPENING. + */ onAnimationEnd: () => void; } +/** + * Returns the final stage after an animation completes. + * OPENING/RETURNING -> ACTIVE; CLOSING -> INACTIVE; BACKGROUNDING -> BACKGROUNDED. + */ +const getNextStage = ( + stage: EuiFlyoutActivityStage +): EuiFlyoutActivityStage | null => { + switch (stage) { + case STAGE_OPENING: + case STAGE_RETURNING: + return STAGE_ACTIVE; + case STAGE_CLOSING: + return STAGE_INACTIVE; + case STAGE_BACKGROUNDING: + return STAGE_BACKGROUNDED; + default: + return null; + } +}; + /** * Encapsulates all activity-stage transitions and animation-driven updates * for managed flyouts. @@ -44,6 +72,7 @@ export interface UseFlyoutActivityStageReturn { export const useFlyoutActivityStage = ({ flyoutId, level, + shouldAnimate = false, }: UseFlyoutActivityStageParams) => { const ctx = useFlyoutManager(); const state = ctx?.state; @@ -74,83 +103,68 @@ export const useFlyoutActivityStage = ({ stageRef.current = stage; } + const transitionTo = useCallback( + (nextStage: EuiFlyoutActivityStage) => { + ctx?.dispatch?.(setActivityStage(flyoutId, nextStage)); + stageRef.current = nextStage; + }, + [ctx, flyoutId] + ); + /** - * 1. ACTIVE -> CLOSING when no longer the active flyout. - * 2. INACTIVE -> RETURNING when it becomes active again (e.g., reopened or brought forward). - * 3. (Main flyout only) ACTIVE + stacked + has child -> BACKGROUNDING (begin background animation). - * 4. (Main only) BACKGROUNDED/BACKGROUNDING + (child gone OR side-by-side) -> RETURNING (bring main to foreground). - * - * Any stages that depend on animation end (OPENING, RETURNING, CLOSING, BACKGROUNDING) are finalized in `onAnimationEnd`. + * 1. ACTIVE -> CLOSING (or INACTIVE when !shouldAnimate) when no longer the active flyout. + * 2. INACTIVE -> RETURNING (or ACTIVE when !shouldAnimate) when it becomes active again. + * 3. (Main only) ACTIVE + stacked + has child -> BACKGROUNDING (or BACKGROUNDED when !shouldAnimate). + * 4. (Main only) BACKGROUNDED/BACKGROUNDING + (child gone OR side-by-side) -> RETURNING (or ACTIVE when !shouldAnimate). */ useEffect(() => { const s = stageRef.current; let next: EuiFlyoutActivityStage | null = null; - if (s === STAGE_ACTIVE && !isActive) next = STAGE_CLOSING; - else if (s === STAGE_INACTIVE && isActive) { - next = STAGE_RETURNING; + if (s === STAGE_ACTIVE && !isActive) { + next = shouldAnimate ? STAGE_CLOSING : STAGE_INACTIVE; + } else if (s === STAGE_INACTIVE && isActive) { + next = shouldAnimate ? STAGE_RETURNING : STAGE_ACTIVE; } else if ( level === LEVEL_MAIN && isActive && s === STAGE_ACTIVE && hasChild && layoutMode === LAYOUT_MODE_STACKED - ) - next = STAGE_BACKGROUNDING; - else if ( + ) { + next = shouldAnimate ? STAGE_BACKGROUNDING : STAGE_BACKGROUNDED; + } else if ( level === LEVEL_MAIN && (s === STAGE_BACKGROUNDED || s === STAGE_BACKGROUNDING) && (!hasChild || layoutMode === LAYOUT_MODE_SIDE_BY_SIDE) - ) - next = STAGE_RETURNING; - - if (next && next !== s) { - ctx?.dispatch?.(setActivityStage(flyoutId, next)); - stageRef.current = next; + ) { + next = shouldAnimate ? STAGE_RETURNING : STAGE_ACTIVE; } - }, [isActive, hasChild, layoutMode, level, ctx, flyoutId, stage]); - /** - * Get the stage to transition to for given current stage. - * Returns `null` if stage should remain unchanged. - * - * Stage transitions: - * - OPENING / RETURNING -> ACTIVE - * - CLOSING -> INACTIVE - * - BACKGROUNDING -> BACKGROUNDED - */ - const getNextStage = ( - stage: EuiFlyoutActivityStage - ): EuiFlyoutActivityStage | null => { - switch (stage) { - case STAGE_OPENING: - case STAGE_RETURNING: - return STAGE_ACTIVE; - - case STAGE_CLOSING: - return STAGE_INACTIVE; - - case STAGE_BACKGROUNDING: - return STAGE_BACKGROUNDED; + if (next && next !== s) { + transitionTo(next); } - - return null; - }; + }, [ + isActive, + hasChild, + layoutMode, + level, + shouldAnimate, + transitionTo, + stage, + ]); /** - * onAnimationEnd event handler that must be passed to EuiFlyout. - * It handles transitions between stages and updates activity stage - * in EuiFlyoutManagerContext. + * onAnimationEnd: browser signal when a CSS animation completes. + * Calls transitionTo to move to the final stage (e.g. CLOSING -> INACTIVE). */ const onAnimationEnd = useCallback(() => { const currentStage = stageRef.current; const nextStage = getNextStage(currentStage); - if (nextStage && nextStage !== currentStage) { - ctx?.dispatch?.(setActivityStage(flyoutId, nextStage)); - stageRef.current = nextStage; + transitionTo(nextStage); } - }, [ctx, flyoutId]); + }, [transitionTo]); return { activityStage: stage, onAnimationEnd }; }; diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts index a4199a294558..b74d0c54f4f8 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts +++ b/packages/eui/src/components/flyout/manager/flyout_managed.styles.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { css, keyframes } from '@emotion/react'; +import { css } from '@emotion/react'; import { euiCanAnimate, logicalCSS } from '../../../global_styling'; import { UseEuiTheme } from '../../../services'; import { @@ -36,54 +36,6 @@ export const euiManagedFlyoutStyles = (euiThemeContext: UseEuiTheme) => { side: _EuiFlyoutSide = DEFAULT_SIDE, level: EuiFlyoutLevel ) => { - // Animation for moving flyout backwards in 3D space (z-axis) when inactive - const euiFlyoutSlideBack3D = keyframes` - from { - transform: translateZ(0) translateX(0) scale(1); - filter: blur(0px); - opacity: 1; - } - to { - transform: translateZ(-1500px) translateX(${ - side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)' - }) scale(0.5); - filter: blur(3px); - opacity: 0.6; - } - `; - - // Animation for bringing flyout forward from 3D space when transitioning to active - const euiFlyoutSlideForward3D = keyframes` - from { - transform: translateZ(-500px) translateX(${ - side === 'left' ? 'calc(-100vw - 100%)' : 'calc(100vw + 100%)' - }) scale(0.85); - filter: blur(3px); - opacity: 0.6; - } - to { - transform: translateZ(0) translateX(0) scale(1); - filter: blur(0px); - opacity: 1; - } - `; - // When flyout is becoming inactive, animate backwards in 3D space - const inactiveTransition = css` - ${euiCanAnimate} { - animation: ${euiFlyoutSlideBack3D} ${euiTheme.animation.extraSlow} - ${euiTheme.animation.resistance} forwards; - pointer-events: none; - } - `; - - // When flyout is becoming active from a backgrounded state, animate forward in 3D space - const returningTransition = css` - ${euiCanAnimate} { - animation: ${euiFlyoutSlideForward3D} ${euiTheme.animation.normal} - ${euiTheme.animation.resistance} forwards; - } - `; - const noTransition = css` ${euiCanAnimate} { animation: none; @@ -115,19 +67,19 @@ export const euiManagedFlyoutStyles = (euiThemeContext: UseEuiTheme) => { return [activeFlyout, noTransition]; case STAGE_BACKGROUNDING: - return [inactiveTransition]; + return [inactiveFlyout, noTransition]; case STAGE_BACKGROUNDED: return [inactiveFlyout, noTransition]; case STAGE_RETURNING: - return [activeFlyout, returningTransition]; + return [activeFlyout, noTransition]; case STAGE_INACTIVE: return [inactiveFlyout, noTransition]; case STAGE_CLOSING: - return [inactiveTransition]; + return [inactiveFlyout, noTransition]; } }, managedFlyout: css` diff --git a/packages/eui/src/components/flyout/manager/flyout_managed.tsx b/packages/eui/src/components/flyout/manager/flyout_managed.tsx index 211874f49b4a..bc77c638c6c1 100644 --- a/packages/eui/src/components/flyout/manager/flyout_managed.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_managed.tsx @@ -14,6 +14,7 @@ import React, { useState, forwardRef, } from 'react'; +import { css } from '@emotion/react'; import { flushSync } from 'react-dom'; import { useCombinedRefs, useEuiMemoizedStyles } from '../../../services'; import { useEuiI18n } from '../../i18n'; @@ -54,6 +55,7 @@ import { */ export interface EuiManagedFlyoutProps extends EuiFlyoutComponentProps { level: EuiFlyoutLevel; + historyKey?: symbol; flyoutMenuProps?: Omit; onActive?: () => void; } @@ -82,6 +84,7 @@ export const EuiManagedFlyout = forwardRef( level, size: sizeProp, minWidth, + historyKey, css: customCss, flyoutMenuProps: _flyoutMenuProps, ...props @@ -129,6 +132,13 @@ export const EuiManagedFlyout = forwardRef( ?.size : undefined; + // Animate opening only for the first main flyout (sole session) or first child (no prior child in session). + const shouldAnimateOpening = + level === LEVEL_MAIN + ? (managerSessions?.length ?? 0) <= 1 && + currentSession?.mainFlyoutId === flyoutId + : (session?.childHistory?.length ?? 0) === 0; + const styles = useEuiMemoizedStyles(euiManagedFlyoutStyles); // Set default size based on level: main defaults to 'm', child defaults to 's' @@ -212,6 +222,7 @@ export const EuiManagedFlyout = forwardRef( title!, level, size as string, + level === LEVEL_MAIN ? historyKey : undefined, _flyoutMenuProps?.iconType, typeof minWidth === 'number' ? minWidth : undefined ); @@ -237,6 +248,7 @@ export const EuiManagedFlyout = forwardRef( level, size, minWidth, + historyKey, _flyoutMenuProps?.iconType, addFlyout, closeFlyout, @@ -305,6 +317,7 @@ export const EuiManagedFlyout = forwardRef( const { activityStage, onAnimationEnd } = useFlyoutActivityStage({ flyoutId, level, + shouldAnimate: false, }); // Note: history controls are only relevant for main flyouts @@ -337,6 +350,11 @@ export const EuiManagedFlyout = forwardRef( styles.managedFlyout, customCss, styles.stage(activityStage, props.side, level), + // Suppress EuiFlyout's built-in opening animation for non-initial flyouts. + !shouldAnimateOpening && + css` + animation-duration: 0s !important; + `, ]} {...{ ...props, diff --git a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx index b27f2db19f4b..b105d59db656 100644 --- a/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx +++ b/packages/eui/src/components/flyout/manager/flyout_sessions.stories.tsx @@ -27,7 +27,6 @@ import { EuiFlexItem, EuiFlyoutBody, EuiFlyoutHeader, - EuiPanel, EuiProvider, EuiSpacer, EuiSwitch, @@ -56,6 +55,8 @@ interface FlyoutSessionProps { mainMaxWidth?: number; childSize: 's' | 'm' | 'fill'; childMaxWidth?: number; + /** Optional. When set, flyouts in this session share history with others using the same Symbol. */ + historyKey?: symbol; } const DisplayContext: React.FC<{ title: string }> = ({ title }) => { @@ -109,7 +110,14 @@ const SessionChildFlyout: React.FC<{ ); const FlyoutSession: React.FC = (props) => { - const { title, mainSize, childSize, mainMaxWidth, childMaxWidth } = props; + const { + title, + mainSize, + childSize, + mainMaxWidth, + childMaxWidth, + historyKey, + } = props; const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [isChild1FlyoutVisible, setIsChild1FlyoutVisible] = useState(false); @@ -205,6 +213,7 @@ const FlyoutSession: React.FC = (props) => { = ({ size }) => { }; const MultiSessionFlyoutDemo: React.FC = () => { + const parksHistoryKey = React.useRef(Symbol()).current; + const sanitationHistoryKey = React.useRef(Symbol()).current; + const listItems = [ { - title: 'Session A: main size = s, child size = s', + title: 'Parks (shared history)', description: ( - + + + + + + + + + + + ), }, { - title: 'Session B: main size = m, child size = s', + title: 'Sanitation (shared history)', description: ( - + + + + + + + + + + + ), }, { - title: 'Session C: main size = s, child size = fill', + title: 'Permits (no historyKey: unique group)', description: ( - ), - }, - { - title: 'Session D: main size = fill, child size = s', - description: ( - - ), - }, - { - title: 'Session E: main size = fill, child size = m', - description: ( - - ), - }, - { - title: 'Session F: main size = m, child size = fill (maxWidth 1000px)', - description: ( - ), }, @@ -507,6 +525,14 @@ const MultiSessionFlyoutDemo: React.FC = () => { return ( <> + +

+ "Parks", "Sanitation", and "Permits" are + separate groups of scoped history. Navigating with the Back button and + the history menu does not cross over into other groups. +

+
+ { export const MultiSessionExample: StoryObj = { name: 'Multi-session example', + parameters: { + docs: { + description: { + story: + 'Parks and Sanitation use a shared `historyKey` so Back/history are scoped to each domain. Permits omits `historyKey` so it is alone in its history group. Open flyouts from different sections to see that histories do not mix.', + }, + }, + }, render: () => , }; -const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ - parentId, -}) => { +const ExternalRootChildFlyout: React.FC<{ + historyKey: symbol; + parentId: string; +}> = ({ historyKey, parentId }) => { const [isOpen, setIsOpen] = useState(false); const handleToggle = () => { @@ -586,13 +621,9 @@ const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ }; return ( - - -

Root within {parentId}

-
- + <> - Open child flyout + {`Open ${parentId} child flyout in external root`} {isOpen && ( = ({ flyoutMenuProps={{ title: `Child flyout of ${parentId}` }} resizable={false} data-test-subj="child-flyout-in-new-root" + historyKey={historyKey} > @@ -617,11 +649,14 @@ const ExternalRootChildFlyout: React.FC<{ parentId: string }> = ({ )} -
+ ); }; -const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { +const ExternalRootFlyout: React.FC<{ id: string; historyKey: symbol }> = ({ + id, + historyKey, +}) => { const [isOpen, setIsOpen] = useState(false); const buttonContainerRef = useRef(null); const buttonRootRef = useRef(null); @@ -643,7 +678,7 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { const newRoot = createRoot(buttonContainerRef.current); newRoot.render( - + ); buttonRootRef.current = newRoot; @@ -667,13 +702,9 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { }, []); return ( - - -

{id}

-
- - setIsOpen((prev) => !prev)}> - {isOpen ? 'Close flyout' : 'Open flyout'} + <> + setIsOpen((prev) => !prev)} disabled={isOpen}> + {`Open ${id} flyout`} {isOpen && ( = ({ id }) => { ownFocus={false} flyoutMenuProps={{ title: `${id} flyout` }} resizable={true} + historyKey={historyKey} > @@ -711,10 +743,12 @@ const ExternalRootFlyout: React.FC<{ id: string }> = ({ id }) => { )} -
+ ); }; +const multiRootHistoryKey = Symbol('multiRootSharedHistory'); + const MultiRootFlyoutDemo: React.FC = () => { const secondaryRootRef = useRef(null); const tertiaryRootRef = useRef(null); @@ -735,7 +769,7 @@ const MultiRootFlyoutDemo: React.FC = () => { const root = createRoot(container); root.render( - + ); return root; diff --git a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx index 418ccdd2f3f4..4b7c4e425b34 100644 --- a/packages/eui/src/components/flyout/manager/layout_mode.test.tsx +++ b/packages/eui/src/components/flyout/manager/layout_mode.test.tsx @@ -85,7 +85,11 @@ const buildMockManagerState = ({ session = { mainFlyoutId: 'main-1', childFlyoutId: 'child-1' as string | null, - } as { mainFlyoutId: string; childFlyoutId: string | null } | null, + } as { + mainFlyoutId: string; + childFlyoutId: string | null; + historyKey?: symbol; + } | null, mainFlyout = { flyoutId: 'main-1', level: 'main' as const, @@ -100,7 +104,9 @@ const buildMockManagerState = ({ } as { flyoutId: string; level: string; size: string; width?: number } | null, } = {}) => ({ layoutMode, - sessions: session ? [session] : [], + sessions: session + ? [{ ...session, historyKey: session.historyKey ?? Symbol() }] + : [], flyouts: [mainFlyout, childFlyout].filter(Boolean), }); diff --git a/packages/eui/src/components/flyout/manager/reducer.test.ts b/packages/eui/src/components/flyout/manager/reducer.test.ts index fcc3d51985a5..2e7278a7b161 100644 --- a/packages/eui/src/components/flyout/manager/reducer.test.ts +++ b/packages/eui/src/components/flyout/manager/reducer.test.ts @@ -73,14 +73,17 @@ describe('flyoutManagerReducer', () => { }); expect(newState.sessions).toHaveLength(1); - expect(newState.sessions[0]).toEqual({ - mainFlyoutId: 'main-1', - childFlyoutId: null, - childHistory: [], - title: 'main', - iconType: undefined, - zIndex: 0, - }); + expect(newState.sessions[0]).toMatchInlineSnapshot(` + { + "childFlyoutId": null, + "childHistory": [], + "historyKey": Symbol(), + "iconType": undefined, + "mainFlyoutId": "main-1", + "title": "main", + "zIndex": 0, + } + `); }); it('should store iconType on session when addFlyout is called with iconType', () => { @@ -89,6 +92,7 @@ describe('flyoutManagerReducer', () => { 'Session A', LEVEL_MAIN, 'm', + undefined, 'faceHappy' ); const newState = flyoutManagerReducer(initialState, action); @@ -183,6 +187,7 @@ describe('flyoutManagerReducer', () => { 'Child 1 Updated', LEVEL_CHILD, undefined, + undefined, 'starFilled' ) ); @@ -214,22 +219,39 @@ describe('flyoutManagerReducer', () => { ); expect(state.sessions).toHaveLength(2); - expect(state.sessions[0]).toEqual({ - mainFlyoutId: 'main-1', - childFlyoutId: 'child-1', - childTitle: 'child', - childIconType: undefined, - childHistory: [], - title: 'main', - zIndex: 0, - }); - expect(state.sessions[1]).toEqual({ - mainFlyoutId: 'main-2', - childFlyoutId: null, - childHistory: [], - title: 'main', - zIndex: 3, - }); + expect(state.sessions[0]).toMatchInlineSnapshot(` + { + "childFlyoutId": "child-1", + "childHistory": [], + "childIconType": undefined, + "childTitle": "child", + "historyKey": Symbol(), + "iconType": undefined, + "mainFlyoutId": "main-1", + "title": "main", + "zIndex": 0, + } + `); + expect(state.sessions[1]).toMatchInlineSnapshot(` + { + "childFlyoutId": null, + "childHistory": [], + "historyKey": Symbol(), + "iconType": undefined, + "mainFlyoutId": "main-2", + "title": "main", + "zIndex": 3, + } + `); + }); + + it('should store historyKey on session when addFlyout main is called with historyKey', () => { + const key = Symbol('shared'); + const action = addFlyout('main-1', 'Session A', LEVEL_MAIN, 'm', key); + const newState = flyoutManagerReducer(initialState, action); + + expect(newState.sessions).toHaveLength(1); + expect(newState.sessions[0].historyKey).toBe(key); }); }); @@ -360,14 +382,15 @@ describe('flyoutManagerReducer', () => { }); it('should close all sessions and preserve unmanaged flyouts', () => { - // Setup: add managed and unmanaged flyouts + const historyKey = Symbol(); + // Setup: add managed and unmanaged flyouts (same historyKey so closeAll closes both sessions) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Main 1', LEVEL_MAIN) + addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Main 2', LEVEL_MAIN) + addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer(state, addUnmanagedFlyout('unmanaged-1')); @@ -375,7 +398,7 @@ describe('flyoutManagerReducer', () => { expect(state.flyouts).toHaveLength(2); expect(state.unmanagedFlyouts).toHaveLength(1); - // Close all flyouts + // Close all flyouts (current history group = both sessions) const action = closeAllFlyouts(); state = flyoutManagerReducer(state, action); @@ -386,10 +409,11 @@ describe('flyoutManagerReducer', () => { }); it('should close all sessions including child flyouts', () => { - // Setup: add sessions with children + const historyKey = Symbol(); + // Setup: add sessions with children (same historyKey) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Main 1', LEVEL_MAIN) + addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, @@ -397,13 +421,13 @@ describe('flyoutManagerReducer', () => { ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Main 2', LEVEL_MAIN) + addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey) ); expect(state.sessions).toHaveLength(2); expect(state.flyouts).toHaveLength(3); - // Close all flyouts + // Close all flyouts (current group = both sessions) const action = closeAllFlyouts(); state = flyoutManagerReducer(state, action); @@ -420,15 +444,43 @@ describe('flyoutManagerReducer', () => { expect(newState).toEqual(initialState); }); + it('should close only current history group when multiple groups exist', () => { + const keyA = Symbol(); + const keyB = Symbol(); + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, keyA) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, keyB) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA) + ); + + expect(state.sessions).toHaveLength(3); + + // closeAllFlyouts from top (main-3, keyA) removes only sessions with keyA: main-3 and main-1 + state = flyoutManagerReducer(state, closeAllFlyouts()); + + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].mainFlyoutId).toBe('main-2'); + expect(state.flyouts).toHaveLength(1); + expect(state.flyouts[0].flyoutId).toBe('main-2'); + }); + it('should reset currentZIndex value when all unmanaged and managed flyouts are closed', () => { - // Setup: add managed and unmanaged flyouts + const historyKey = Symbol(); + // Setup: add managed and unmanaged flyouts (same historyKey) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Main 1', LEVEL_MAIN) + addFlyout('main-1', 'Main 1', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Main 2', LEVEL_MAIN) + addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer(state, addUnmanagedFlyout('unmanaged-1')); @@ -439,7 +491,7 @@ describe('flyoutManagerReducer', () => { state = flyoutManagerReducer(state, closeUnmanagedFlyout('unmanaged-1')); expect(state.currentZIndex).toEqual(8); - // Close all flyouts, currentZIndex should reset to 0 + // Close all flyouts (both sessions in group), currentZIndex should reset to 0 state = flyoutManagerReducer(state, closeAllFlyouts()); expect(state.currentZIndex).toBe(0); }); @@ -606,20 +658,21 @@ describe('flyoutManagerReducer', () => { describe('ACTION_GO_BACK', () => { it('should remove the current session and its flyouts', () => { - // Setup: create two sessions + const historyKey = Symbol(); + // Setup: create two sessions (same historyKey so goBack only removes one) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Session A', LEVEL_MAIN) + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Session B', LEVEL_MAIN) + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, historyKey) ); expect(state.sessions).toHaveLength(2); expect(state.flyouts).toHaveLength(2); - // Go back (should remove Session B) + // Go back (should remove Session B only, same group) const action = goBack(); state = flyoutManagerReducer(state, action); @@ -631,14 +684,15 @@ describe('flyoutManagerReducer', () => { }); it('should remove current session with child flyout', () => { - // Setup: create session with child + const historyKey = Symbol(); + // Setup: create session with child (same historyKey) let state = flyoutManagerReducer( initialState, - addFlyout('main-1', 'Session A', LEVEL_MAIN) + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, - addFlyout('main-2', 'Session B', LEVEL_MAIN) + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, @@ -649,7 +703,7 @@ describe('flyoutManagerReducer', () => { expect(state.sessions[1].childFlyoutId).toBe('child-2'); expect(state.flyouts).toHaveLength(3); - // Go back (should remove Session B and its child) + // Go back (should remove Session B and its child only) const action = goBack(); state = flyoutManagerReducer(state, action); @@ -659,6 +713,39 @@ describe('flyoutManagerReducer', () => { expect(state.flyouts[0].flyoutId).toBe('main-1'); }); + it('should keep other groups and restore previous session in current group when going back', () => { + const keyA = Symbol(); + const keyB = Symbol(); + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A', LEVEL_MAIN, undefined, keyA) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B', LEVEL_MAIN, undefined, keyB) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA) + ); + + expect(state.sessions).toHaveLength(3); + + // goBack from main-3 (keyA): remove current session and bring prior keyA session to top. + // main-2 (keyB) remains in state and is restored when keyA group closes. + state = flyoutManagerReducer(state, goBack()); + + expect(state.sessions).toHaveLength(2); + expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'main-2', + 'main-1', + ]); + expect(state.flyouts.map((f) => f.flyoutId)).toEqual([ + 'main-1', + 'main-2', + ]); + }); + it('should do nothing when no sessions exist', () => { const action = goBack(); const newState = flyoutManagerReducer(initialState, action); @@ -807,6 +894,41 @@ describe('flyoutManagerReducer', () => { ]); }); + it('should preserve intervening groups when navigating to prior session in current history group', () => { + const keyA = Symbol(); + const keyB = Symbol(); + let state = flyoutManagerReducer( + initialState, + addFlyout('main-1', 'Session A1', LEVEL_MAIN, undefined, keyA) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-2', 'Session B1', LEVEL_MAIN, undefined, keyB) + ); + state = flyoutManagerReducer( + state, + addFlyout('main-3', 'Session A2', LEVEL_MAIN, undefined, keyA) + ); + + expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'main-1', + 'main-2', + 'main-3', + ]); + + // Navigate from A2 to A1: remove newer same-group session(s) only and keep B1. + state = flyoutManagerReducer(state, goToFlyout('main-1')); + + expect(state.sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'main-2', + 'main-1', + ]); + expect(state.flyouts.map((f) => f.flyoutId)).toEqual([ + 'main-1', + 'main-2', + ]); + }); + it('should do nothing when target flyout does not exist', () => { // Setup: create session let state = flyoutManagerReducer( @@ -1062,26 +1184,27 @@ describe('flyoutManagerReducer', () => { }); it('should handle multiple sessions with children', () => { + const historyKey = Symbol(); let state = initialState; - // Session 1: main + child + // Session 1: main + child (shared historyKey) state = flyoutManagerReducer( state, - addFlyout('main-1', 'main', LEVEL_MAIN) + addFlyout('main-1', 'main', LEVEL_MAIN, undefined, historyKey) ); state = flyoutManagerReducer( state, addFlyout('child-1', 'child', LEVEL_CHILD) ); - // Session 2: main only + // Session 2: main only (same historyKey) state = flyoutManagerReducer( state, - addFlyout('main-2', 'main', LEVEL_MAIN) + addFlyout('main-2', 'main', LEVEL_MAIN, undefined, historyKey) ); expect(state.sessions).toHaveLength(2); - expect(state.sessions[0]).toEqual({ + expect(state.sessions[0]).toMatchObject({ mainFlyoutId: 'main-1', childFlyoutId: 'child-1', childTitle: 'child', @@ -1090,7 +1213,7 @@ describe('flyoutManagerReducer', () => { title: 'main', zIndex: 0, }); - expect(state.sessions[1]).toEqual({ + expect(state.sessions[1]).toMatchObject({ mainFlyoutId: 'main-2', childFlyoutId: null, childHistory: [], @@ -1098,7 +1221,7 @@ describe('flyoutManagerReducer', () => { zIndex: 3, }); - // Close first session's main flyout + // Close current history group (both sessions share key, so both close) state = flyoutManagerReducer(state, closeAllFlyouts()); expect(state.sessions).toHaveLength(0); diff --git a/packages/eui/src/components/flyout/manager/reducer.ts b/packages/eui/src/components/flyout/manager/reducer.ts index 210628247b3b..8243960b6a01 100644 --- a/packages/eui/src/components/flyout/manager/reducer.ts +++ b/packages/eui/src/components/flyout/manager/reducer.ts @@ -47,6 +47,35 @@ export const initialState: EuiFlyoutManagerState = { unmanagedFlyouts: [], }; +const addSessionFlyoutsToRemove = ( + session: FlyoutSession, + flyoutsToRemove: Set +) => { + flyoutsToRemove.add(session.mainFlyoutId); + if (session.childFlyoutId) { + flyoutsToRemove.add(session.childFlyoutId); + } + (session.childHistory ?? []).forEach((e) => flyoutsToRemove.add(e.flyoutId)); +}; + +const moveHistoryGroupToTop = ( + sessions: FlyoutSession[], + historyKey: symbol +): FlyoutSession[] => { + const groupSessions: FlyoutSession[] = []; + const otherSessions: FlyoutSession[] = []; + + sessions.forEach((session) => { + if (session.historyKey === historyKey) { + groupSessions.push(session); + } else { + otherSessions.push(session); + } + }); + + return [...otherSessions, ...groupSessions]; +}; + /** * Reducer handling all flyout manager actions and state transitions. */ @@ -93,7 +122,8 @@ export function flyoutManagerReducer( // - For a `child` flyout, attach it to the most recent session; if no // session exists, do nothing (invalid child without a parent). case ACTION_ADD: { - const { flyoutId, title, level, size, iconType, minWidth } = action; + const { flyoutId, title, level, size, historyKey, iconType, minWidth } = + action; const isDuplicate = state.flyouts.some((f) => f.flyoutId === flyoutId); const isIdempotentChild = @@ -125,6 +155,7 @@ export function flyoutManagerReducer( childFlyoutId: null, childHistory: [], zIndex: state.currentZIndex, + historyKey: historyKey ?? Symbol(), }; return { @@ -289,22 +320,41 @@ export function flyoutManagerReducer( return { ...state, sessions: updatedSessions, flyouts: newFlyouts }; } - // Unregister all flyouts. + // Unregister all flyouts (within the current history group only). case ACTION_CLOSE_ALL: { if (state.sessions.length === 0) { return state; } - // Reset current z-index to 0 only if no unmanaged flyouts remain. + const currentSessionIndex = state.sessions.length - 1; + const currentSession = state.sessions[currentSessionIndex]; + const currentKey = currentSession.historyKey; + + // Remove all sessions that have the current historyKey (entire group) + const newSessions = state.sessions.filter( + (s) => s.historyKey !== currentKey + ); + const flyoutsToRemove = new Set(); + state.sessions.forEach((session) => { + if (session.historyKey === currentKey) { + addSessionFlyoutsToRemove(session, flyoutsToRemove); + } + }); + + const newFlyouts = state.flyouts.filter( + (f) => !flyoutsToRemove.has(f.flyoutId) + ); + let newCurrentZIndex = state.currentZIndex; - if (state.unmanagedFlyouts.length === 0) { + if (newSessions.length === 0 && state.unmanagedFlyouts.length === 0) { newCurrentZIndex = 0; } return { - ...initialState, + ...state, + sessions: newSessions, + flyouts: newFlyouts, currentZIndex: newCurrentZIndex, - unmanagedFlyouts: state.unmanagedFlyouts, }; } @@ -352,7 +402,7 @@ export function flyoutManagerReducer( return { ...state, flyouts: updatedFlyouts }; } - // Go back: pop child history when any, else pop current session + // Go back: pop child history when any, else pop current session (only within same historyKey). case ACTION_GO_BACK: { if (state.sessions.length === 0) { return state; @@ -381,18 +431,26 @@ export function flyoutManagerReducer( } // No child history: pop current session (main + all its children) - const flyoutsToRemove = new Set([currentSession.mainFlyoutId]); - if (currentSession.childFlyoutId) { - flyoutsToRemove.add(currentSession.childFlyoutId); - } - (currentSession.childHistory ?? []).forEach((e) => - flyoutsToRemove.add(e.flyoutId) + const flyoutsToRemove = new Set(); + addSessionFlyoutsToRemove(currentSession, flyoutsToRemove); + + const sessionsWithoutCurrent = state.sessions.slice( + 0, + currentSessionIndex + ); + const hasRemainingInCurrentGroup = sessionsWithoutCurrent.some( + (s) => s.historyKey === currentSession.historyKey ); + const newSessions = hasRemainingInCurrentGroup + ? moveHistoryGroupToTop( + sessionsWithoutCurrent, + currentSession.historyKey + ) + : sessionsWithoutCurrent; const newFlyouts = state.flyouts.filter( (f) => !flyoutsToRemove.has(f.flyoutId) ); - const newSessions = state.sessions.slice(0, currentSessionIndex); return { ...state, sessions: newSessions, flyouts: newFlyouts }; } @@ -449,23 +507,49 @@ export function flyoutManagerReducer( return state; // Target flyout not found } + const currentSession = state.sessions[currentSessionIndex]; + const targetSession = state.sessions[targetSessionIndex]; + + // Group-local navigation: keep other history groups, remove only newer sessions in target's group, + // then bring that group to the top. + if (targetSession.historyKey === currentSession.historyKey) { + const flyoutsToRemove = new Set(); + const sessionsAfterTargetInGroup = state.sessions.filter( + (session, index) => + index > targetSessionIndex && + session.historyKey === targetSession.historyKey + ); + + sessionsAfterTargetInGroup.forEach((session) => { + addSessionFlyoutsToRemove(session, flyoutsToRemove); + }); + + const sessionsWithoutRemoved = state.sessions.filter( + (session) => + !sessionsAfterTargetInGroup.some( + (removed) => removed.mainFlyoutId === session.mainFlyoutId + ) + ); + const reorderedSessions = moveHistoryGroupToTop( + sessionsWithoutRemoved, + targetSession.historyKey + ); + const newFlyouts = state.flyouts.filter( + (f) => !flyoutsToRemove.has(f.flyoutId) + ); + + return { ...state, sessions: reorderedSessions, flyouts: newFlyouts }; + } + const sessionsToClose = state.sessions.slice(targetSessionIndex + 1); const flyoutsToRemove = new Set(); - sessionsToClose.forEach((session) => { - flyoutsToRemove.add(session.mainFlyoutId); - if (session.childFlyoutId) { - flyoutsToRemove.add(session.childFlyoutId); - } - (session.childHistory ?? []).forEach((e) => - flyoutsToRemove.add(e.flyoutId) - ); + addSessionFlyoutsToRemove(session, flyoutsToRemove); }); const newFlyouts = state.flyouts.filter( (f) => !flyoutsToRemove.has(f.flyoutId) ); - const newSessions = state.sessions.slice(0, targetSessionIndex + 1); return { ...state, sessions: newSessions, flyouts: newFlyouts }; diff --git a/packages/eui/src/components/flyout/manager/store.test.ts b/packages/eui/src/components/flyout/manager/store.test.ts index 28ea1c88bce4..90e56e003222 100644 --- a/packages/eui/src/components/flyout/manager/store.test.ts +++ b/packages/eui/src/components/flyout/manager/store.test.ts @@ -55,29 +55,55 @@ describe('Flyout Manager Store', () => { it('should update references when sessions change', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - // Add first flyout - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); + // Add first flyout (with shared historyKey) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const firstHistoryItems = store.historyItems; - // Add second flyout (creates a new session) - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Add second flyout (same historyKey so they share history) + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const secondHistoryItems = store.historyItems; // References should be different since sessions changed expect(secondHistoryItems).not.toBe(firstHistoryItems); - // Should have one history item (the first session) + // Should have one history item (the first session, same group) expect(secondHistoryItems).toHaveLength(1); expect(secondHistoryItems[0].title).toBe('First Flyout'); }); it('should create stable onClick handlers within the same session state', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - // Add two flyouts to create history - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Add two flyouts (same historyKey) to create history + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const firstHistoryItems = store.historyItems; const firstOnClick = firstHistoryItems[0].onClick; @@ -95,15 +121,34 @@ describe('Flyout Manager Store', () => { it('should properly compute history items with correct titles', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - // Create multiple sessions - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); - store.addFlyout('flyout-3', 'Third Flyout', LEVEL_MAIN); + // Create multiple sessions (same historyKey so they share history) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-3', + 'Third Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const historyItems = store.historyItems; - // Should have 2 history items (all previous sessions, in reverse order) + // Should have 2 history items (previous sessions in same group, reverse order) expect(historyItems).toHaveLength(2); expect(historyItems[0].title).toBe('Second Flyout'); expect(historyItems[1].title).toBe('First Flyout'); @@ -111,15 +156,23 @@ describe('Flyout Manager Store', () => { it('should include iconType in history items when sessions were added with iconType', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); store.addFlyout( 'flyout-1', 'First Flyout', LEVEL_MAIN, undefined, + historyKey, 'faceHappy' ); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const historyItems = store.historyItems; @@ -131,10 +184,23 @@ describe('Flyout Manager Store', () => { it('should have functional onClick handlers', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - // Create two sessions - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Create two sessions (same historyKey) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const historyItems = store.historyItems; @@ -147,10 +213,37 @@ describe('Flyout Manager Store', () => { expect(store.getState().sessions[0].mainFlyoutId).toBe('flyout-1'); }); + it('should keep intervening groups when history onClick navigates within a group', () => { + const store = getFlyoutManagerStore(); + const keyA = Symbol(); + const keyB = Symbol(); + + store.addFlyout('a-1', 'A1', LEVEL_MAIN, undefined, keyA); + store.addFlyout('b-1', 'B1', LEVEL_MAIN, undefined, keyB); + store.addFlyout('a-2', 'A2', LEVEL_MAIN, undefined, keyA); + + expect(store.historyItems).toHaveLength(1); + expect(store.historyItems[0].title).toBe('A1'); + + // Navigate to A1 from A2 history item. + store.historyItems[0].onClick(); + + // B1 should still exist and be restored behind the active A group. + expect(store.getState().sessions.map((s) => s.mainFlyoutId)).toEqual([ + 'b-1', + 'a-1', + ]); + expect(store.getState().flyouts.map((f) => f.flyoutId)).toEqual([ + 'a-1', + 'b-1', + ]); + }); + it('should include current session child history first, then previous main sessions (child items most recent first)', () => { const store = getFlyoutManagerStore(); + const historyKey = Symbol(); - store.addFlyout('main-1', 'Main', LEVEL_MAIN); + store.addFlyout('main-1', 'Main', LEVEL_MAIN, undefined, historyKey); store.addFlyout('child-1', 'Child 1', LEVEL_CHILD); store.addFlyout('child-2', 'Child 2', LEVEL_CHILD); @@ -160,10 +253,10 @@ describe('Flyout Manager Store', () => { expect(historyItems[0].title).toBe('Child 1'); expect(historyItems[0].onClick).toBeDefined(); - // Add a second main: current session becomes main-2 (no child). History = previous session's child breadcrumb (current child + child history), most recent first - store.addFlyout('main-2', 'Main 2', LEVEL_MAIN); + // Add a second main (same historyKey): current session becomes main-2 (no child), so history = previous session in group (main-1 had children) + store.addFlyout('main-2', 'Main 2', LEVEL_MAIN, undefined, historyKey); const historyItems2 = store.historyItems; - expect(historyItems2).toHaveLength(2); // Child 2 (main-1's current child), then Child 1 (main-1's child history) + expect(historyItems2).toHaveLength(2); // main-1 had Child 2 and Child 1 in history expect(historyItems2[0].title).toBe('Child 2'); expect(historyItems2[1].title).toBe('Child 1'); }); @@ -180,6 +273,16 @@ describe('Flyout Manager Store', () => { store.addFlyout('child-1', 'Child', LEVEL_CHILD); expect(store.historyItems).toHaveLength(0); }); + + it('should not share history when no historyKey is passed (each session gets unique Symbol)', () => { + const store = getFlyoutManagerStore(); + + store.addFlyout('main-1', 'First', LEVEL_MAIN); + store.addFlyout('main-2', 'Second', LEVEL_MAIN); + + // Each session has its own Symbol, so no shared history - current session has no previous in its group + expect(store.historyItems).toHaveLength(0); + }); }); describe('store subscription', () => { @@ -227,10 +330,23 @@ describe('Flyout Manager Store', () => { it('should emit CLOSE_SESSION event when a session is removed by going back', () => { const store = getFlyoutManagerStore(); const eventListener = jest.fn(); + const historyKey = Symbol(); - // Create two sessions - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Create two sessions (same historyKey so goBack only removes one) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; expect(sessions).toHaveLength(2); @@ -238,10 +354,10 @@ describe('Flyout Manager Store', () => { // Subscribe to events const unsubscribe = store.subscribeToEvents(eventListener); - // Go back one session + // Go back one session (within same history group) store.goBack(); - // Should have emitted CLOSE_SESSION for the second session + // Should have emitted CLOSE_SESSION for the second session only expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: 'CLOSE_SESSION', @@ -254,11 +370,30 @@ describe('Flyout Manager Store', () => { it('should emit CLOSE_SESSION event when navigating to a previous flyout', () => { const store = getFlyoutManagerStore(); const eventListener = jest.fn(); + const historyKey = Symbol(); - // Create three sessions - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); - store.addFlyout('flyout-3', 'Third Flyout', LEVEL_MAIN); + // Create three sessions (same historyKey) + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-3', + 'Third Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; expect(sessions).toHaveLength(3); @@ -287,19 +422,32 @@ describe('Flyout Manager Store', () => { const store = getFlyoutManagerStore(); const eventListener1 = jest.fn(); const eventListener2 = jest.fn(); + const historyKey = Symbol(); - store.addFlyout('flyout-1', 'First Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + store.addFlyout( + 'flyout-1', + 'First Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; store.subscribeToEvents(eventListener1); store.subscribeToEvents(eventListener2); - // Go back one session + // Go back one session (same group) store.goBack(); - // Both listeners should have been called + // Both listeners should have been called once (one session removed) expect(eventListener1).toHaveBeenCalledTimes(1); expect(eventListener1).toHaveBeenCalledWith({ type: 'CLOSE_SESSION', @@ -340,20 +488,33 @@ describe('Flyout Manager Store', () => { it('should emit CLOSE_SESSION events when all sessions are removed by closeAllFlyouts', () => { const store = getFlyoutManagerStore(); const eventListener = jest.fn(); + const historyKey = Symbol(); - // Create sessions - store.addFlyout('flyout-1', 'Test Flyout', LEVEL_MAIN); - store.addFlyout('flyout-2', 'Second Flyout', LEVEL_MAIN); + // Create sessions (same historyKey so closeAllFlyouts closes both) + store.addFlyout( + 'flyout-1', + 'Test Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); + store.addFlyout( + 'flyout-2', + 'Second Flyout', + LEVEL_MAIN, + undefined, + historyKey + ); const sessions = store.getState().sessions; expect(sessions).toHaveLength(2); const unsubscribe = store.subscribeToEvents(eventListener); - // Closing flyout will close all sessions + // closeAllFlyouts closes only current history group (both sessions share key) store.closeAllFlyouts(); - // Should have emitted CLOSE_SESSION + // Should have emitted CLOSE_SESSION for both sessions expect(eventListener).toHaveBeenCalledTimes(2); expect(eventListener).toHaveBeenNthCalledWith(1, { type: 'CLOSE_SESSION', diff --git a/packages/eui/src/components/flyout/manager/store.ts b/packages/eui/src/components/flyout/manager/store.ts index 011cf23cd581..ee3b7026aa1d 100644 --- a/packages/eui/src/components/flyout/manager/store.ts +++ b/packages/eui/src/components/flyout/manager/store.ts @@ -51,6 +51,7 @@ export interface FlyoutManagerStore { title: string, level?: EuiFlyoutLevel, size?: string, + historyKey?: symbol, iconType?: IconType, minWidth?: number ) => void; @@ -116,12 +117,21 @@ function createStore( currentSessionIndex >= 0 ? currentState.sessions[currentSessionIndex] : null; + + if (!currentSession) { + return []; + } + const previousSessions = currentState.sessions.slice( 0, currentSessionIndex ); + // Only include sessions in the same history group (same historyKey reference) + const previousSessionsInGroup = previousSessions.filter( + (session) => session.historyKey === currentSession.historyKey + ); - const childHistory = currentSession?.childHistory ?? []; + const childHistory = currentSession.childHistory ?? []; const childItems = [...childHistory].reverse().map((entry) => ({ title: entry.title, iconType: entry.iconType, @@ -130,14 +140,14 @@ function createStore( }, })); - // Previous sessions: list each session's current child then its child history (so all travelled entries appear) + // Previous sessions (same group): list each session's current child then its child history const previousSessionItems: Array<{ title: string; iconType?: IconType; onClick: () => void; }> = []; - for (let i = previousSessions.length - 1; i >= 0; i--) { - const session = previousSessions[i]; + for (let i = previousSessionsInGroup.length - 1; i >= 0; i--) { + const session = previousSessionsInGroup[i]; const mainTitle = session.title; const mainFlyoutId = session.mainFlyoutId; const history = session.childHistory ?? []; @@ -215,9 +225,17 @@ function createStore( subscribe, subscribeToEvents, dispatch, - addFlyout: (flyoutId, title, level, size, iconType, minWidth) => + addFlyout: (flyoutId, title, level, size, historyKey, iconType, minWidth) => dispatch( - addFlyoutAction(flyoutId, title, level, size, iconType, minWidth) + addFlyoutAction( + flyoutId, + title, + level, + size, + historyKey, + iconType, + minWidth + ) ), closeFlyout: (flyoutId) => dispatch(closeFlyoutAction(flyoutId)), closeAllFlyouts: () => dispatch(closeAllFlyoutsAction()), diff --git a/packages/eui/src/components/flyout/manager/types.ts b/packages/eui/src/components/flyout/manager/types.ts index 37ce31e077cb..58cfa96fe6b1 100644 --- a/packages/eui/src/components/flyout/manager/types.ts +++ b/packages/eui/src/components/flyout/manager/types.ts @@ -73,6 +73,8 @@ export interface FlyoutSession { childIconType?: IconType; /** Stack of child flyouts we navigated away from. */ childHistory: ChildHistoryEntry[]; + /** Key that scopes this session's history; same Symbol reference = same history group. Always set (from action or Symbol()). */ + historyKey: symbol; } export interface PushPaddingOffsets { @@ -111,6 +113,7 @@ export interface FlyoutManagerApi { title: string, level?: EuiFlyoutLevel, size?: string, + historyKey?: symbol, iconType?: IconType, minWidth?: number ) => void; diff --git a/packages/eui/src/components/icon/__snapshots__/icon.test.tsx.snap b/packages/eui/src/components/icon/__snapshots__/icon.test.tsx.snap index 6c6dfc826267..49ac2f2bd784 100644 --- a/packages/eui/src/components/icon/__snapshots__/icon.test.tsx.snap +++ b/packages/eui/src/components/icon/__snapshots__/icon.test.tsx.snap @@ -14422,6 +14422,50 @@ exports[`EuiIcon props type training is rendered 1`] = ` `; +exports[`EuiIcon props type transitionBottomIn is rendered 1`] = ` + + + + +`; + +exports[`EuiIcon props type transitionBottomOut is rendered 1`] = ` + + + + +`; + exports[`EuiIcon props type transitionLeftIn is rendered 1`] = ` & SVGRProps) => ( + + {title ? {title} : null} + + + +); +export const icon = EuiIconTransitionBottomIn; diff --git a/packages/eui/src/components/icon/assets/transition_bottom_out.tsx b/packages/eui/src/components/icon/assets/transition_bottom_out.tsx new file mode 100644 index 000000000000..d29377b7ef27 --- /dev/null +++ b/packages/eui/src/components/icon/assets/transition_bottom_out.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 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 or the Server + * Side Public License, v 1. + */ + +// THIS IS A GENERATED FILE. DO NOT MODIFY MANUALLY. @see scripts/compile-icons.js + +import * as React from 'react'; +import type { SVGProps } from 'react'; +interface SVGRProps { + title?: string; + titleId?: string; +} +const EuiIconTransitionBottomOut = ({ + title, + titleId, + ...props +}: SVGProps & SVGRProps) => ( + + {title ? {title} : null} + + + +); +export const icon = EuiIconTransitionBottomOut; diff --git a/packages/eui/src/components/icon/icon_map.ts b/packages/eui/src/components/icon/icon_map.ts index 4a1bec2cca55..3190e7ab8614 100644 --- a/packages/eui/src/components/icon/icon_map.ts +++ b/packages/eui/src/components/icon/icon_map.ts @@ -579,6 +579,8 @@ export const typeToPathMap = { refreshTime: () => import('./assets/refresh_time'), timeslider: () => import('./assets/clock_control'), // NOTE: To be deprecated in favor of clockControl training: () => import('./assets/presentation'), // NOTE: To be deprecated in favor of presentation, + transitionBottomIn: () => import('./assets/transition_bottom_in'), + transitionBottomOut: () => import('./assets/transition_bottom_out'), transitionLeftIn: () => import('./assets/transition_left_in'), transitionLeftOut: () => import('./assets/transition_left_out'), transitionTopIn: () => import('./assets/transition_top_in'), diff --git a/packages/eui/src/components/icon/svgs/transition_bottom_in.svg b/packages/eui/src/components/icon/svgs/transition_bottom_in.svg new file mode 100644 index 000000000000..9e039dfa6d90 --- /dev/null +++ b/packages/eui/src/components/icon/svgs/transition_bottom_in.svg @@ -0,0 +1 @@ + diff --git a/packages/eui/src/components/icon/svgs/transition_bottom_out.svg b/packages/eui/src/components/icon/svgs/transition_bottom_out.svg new file mode 100644 index 000000000000..cc22af9376fd --- /dev/null +++ b/packages/eui/src/components/icon/svgs/transition_bottom_out.svg @@ -0,0 +1 @@ + diff --git a/packages/eui/src/components/inline_edit/inline_edit_form.test.tsx b/packages/eui/src/components/inline_edit/inline_edit_form.test.tsx index 74cb51ad2a0b..7889ab01fdb4 100644 --- a/packages/eui/src/components/inline_edit/inline_edit_form.test.tsx +++ b/packages/eui/src/components/inline_edit/inline_edit_form.test.tsx @@ -401,13 +401,12 @@ describe('EuiInlineEditForm', () => { target: { value: '' }, }); - fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); - expect(onSave).toHaveBeenCalledTimes(1); - - await act(() => { + await act(async () => { + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); promiseResolve(false); - return expect(promise).resolves.toBe(false); + await expect(promise).resolves.toBe(false); }); + expect(onSave).toHaveBeenCalledTimes(1); expect( queryByTestSubject('euiInlineReadModeButton') @@ -419,13 +418,12 @@ describe('EuiInlineEditForm', () => { target: { value: 'hey there' }, }); - fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); - expect(onSave).toHaveBeenCalledTimes(2); - await act(async () => { + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); promiseResolve(true); - return expect(promise).resolves.toBe(true); + await expect(promise).resolves.toBe(true); }); + expect(onSave).toHaveBeenCalledTimes(2); expect(getByTestSubject('euiInlineReadModeButton')).toBeInTheDocument(); expect(getByText('hey there')).toBeTruthy(); diff --git a/packages/eui/src/components/key_pad_menu/key_pad_menu_item.tsx b/packages/eui/src/components/key_pad_menu/key_pad_menu_item.tsx index a4d2533a6bd3..7ae94606794a 100644 --- a/packages/eui/src/components/key_pad_menu/key_pad_menu_item.tsx +++ b/packages/eui/src/components/key_pad_menu/key_pad_menu_item.tsx @@ -84,7 +84,7 @@ type EuiKeyPadMenuItemPropsForUncheckable = { * Extends the wrapping EuiToolTip props when `betaBadgeLabel` is provided */ betaBadgeTooltipProps?: Partial< - Omit + Omit >; /** * Use `onClick` instead when the item is not `checkable` @@ -321,7 +321,6 @@ export const EuiKeyPadMenuItem: FunctionComponent = ({ {...betaBadgeTooltipProps} title={betaBadgeLabel} content={betaBadgeTooltipContent} - delay="long" > {button}
diff --git a/packages/eui/src/components/list_group/list_group_item.tsx b/packages/eui/src/components/list_group/list_group_item.tsx index e95a8eb609ee..837026c55c53 100644 --- a/packages/eui/src/components/list_group/list_group_item.tsx +++ b/packages/eui/src/components/list_group/list_group_item.tsx @@ -350,7 +350,6 @@ export const EuiListGroupItem: FunctionComponent = ({ ; } const mdSyntaxHref = 'https://guides.github.com/features/mastering-markdown/'; @@ -47,6 +48,7 @@ const mdSyntaxLink = ( export const EuiMarkdownEditorHelpButton = ({ uiPlugins, + tooltipProps, }: EuiMarkdownEditorHelpButtonProps) => { const [isShowingHelpModal, setIsShowingHelpModal] = useState(false); const [isShowingHelpPopover, setIsShowingHelpPopover] = useState(false); @@ -71,7 +73,7 @@ export const EuiMarkdownEditorHelpButton = ({ if (hasUiPluginsWithHelpText) { return ( <> - + {boldItalicButtons.map((item) => ( - + {listButtons.map((item) => ( - + {quoteCodeLinkButtons.map((item) => ( - + {uiPlugins.map(({ name, button }) => { return ( - + { diff --git a/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts b/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts index 211b95a6f769..e6117d8a4934 100644 --- a/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts +++ b/packages/eui/src/components/markdown_editor/plugins/markdown_default_plugins/plugins.test.ts @@ -13,7 +13,7 @@ describe('default plugins', () => { const { parsingPlugins, processingPlugins, uiPlugins } = getDefaultEuiMarkdownPlugins(); - expect(parsingPlugins).toHaveLength(7); + expect(parsingPlugins).toHaveLength(8); expect(Object.keys(processingPlugins[1][1].components)).toHaveLength(8); expect(uiPlugins).toHaveLength(1); @@ -27,7 +27,7 @@ describe('default plugins', () => { exclude: ['tooltip'], }); - expect(parsingPlugins).toHaveLength(6); + expect(parsingPlugins).toHaveLength(7); expect(processingPlugins[1][1].components.tooltipPlugin).toBeUndefined(); expect(uiPlugins).toHaveLength(0); }); @@ -38,7 +38,7 @@ describe('default plugins', () => { exclude: ['checkbox'], }); - expect(parsingPlugins).toHaveLength(6); + expect(parsingPlugins).toHaveLength(7); expect(processingPlugins[1][1].components.checkboxPlugin).toBeUndefined(); expect(uiPlugins).toHaveLength(1); }); @@ -55,7 +55,7 @@ describe('default plugins', () => { ], }); - expect(parsingPlugins).toHaveLength(2); + expect(parsingPlugins).toHaveLength(3); expect(Object.keys(processingPlugins[1][1].components)).toHaveLength(6); expect(uiPlugins).toHaveLength(0); }); diff --git a/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.test.tsx b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.test.tsx new file mode 100644 index 000000000000..3e9c8ad4a737 --- /dev/null +++ b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.test.tsx @@ -0,0 +1,116 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { render } from '../../../../test/rtl'; +import { EuiMarkdownFormat } from '../../index'; + +describe('remarkIntrawordUnderscore', () => { + it('preserves identifiers with double underscores as plain text', () => { + const { container } = render( + + {`ABDC__AppleBanana__c + ABDC__MangoKiwi__c + ABDC__PineappleCherry__c`} + + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('ABDC__AppleBanana__c'); + expect(container).toHaveTextContent('ABDC__MangoKiwi__c'); + expect(container).toHaveTextContent('ABDC__PineappleCherry__c'); + }); + + it('preserves identifiers with single underscores as plain text', () => { + const { container } = render( + {'some_variable_name'} + ); + + expect(container.querySelector('em')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('some_variable_name'); + }); + + it('still applies bold for standalone double underscores', () => { + const { container } = render( + {'__bold text__'} + ); + + expect(container.querySelector('strong')).toHaveTextContent('bold text'); + }); + + it('still applies emphasis for standalone single underscores', () => { + const { container } = render( + {'_italic text_'} + ); + + expect(container.querySelector('em')).toHaveTextContent('italic text'); + }); + + it('handles multiple identifiers in a sentence', () => { + const { container } = render( + + {'Fields ABDC__AppleBanana__c and ABDC__MangoKiwi__c are required'} + + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent( + 'Fields ABDC__AppleBanana__c and ABDC__MangoKiwi__c are required' + ); + }); + + it('preserves trailing double underscores as plain text', () => { + const { container } = render( + {'Mango__Kiwi__'} + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('Mango__Kiwi__'); + }); + + it('preserves leading double underscores as plain text', () => { + const { container } = render( + {'__Mango__Kiwi'} + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('__Mango__Kiwi'); + }); + + it('preserves trailing single underscores as plain text', () => { + const { container } = render( + {'Mango_Kiwi_'} + ); + + expect(container.querySelector('em')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('Mango_Kiwi_'); + }); + + it('preserves mixed double/single trailing underscores as plain text', () => { + const { container } = render( + {'Mango__Kiwi_'} + ); + + expect(container.querySelector('em')).not.toBeInTheDocument(); + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent('Mango__Kiwi_'); + }); + + it('handles edge-case identifiers mixed in a sentence', () => { + const { container } = render( + + {'Check __Mango__Kiwi and Mango__Kiwi__ fields'} + + ); + + expect(container.querySelector('strong')).not.toBeInTheDocument(); + expect(container).toHaveTextContent( + 'Check __Mango__Kiwi and Mango__Kiwi__ fields' + ); + }); +}); diff --git a/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.ts b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.ts new file mode 100644 index 000000000000..b64c050ec078 --- /dev/null +++ b/packages/eui/src/components/markdown_editor/plugins/remark/remark_intraword_underscore.ts @@ -0,0 +1,194 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +// Temporary workaround for https://github.com/elastic/eui/issues/9404 +// remark-parse v8 doesn't implement the CommonMark rule that underscore +// emphasis delimiters flanked by alphanumerics on both sides (intraword) +// cannot open or close emphasis. This causes identifiers like +// `ABCD__PineappleCherry__e` to render with bold formatting. +// This plugin walks the parsed AST and reverses incorrectly applied +// emphasis/strong nodes when both sides are alphanumeric characters. + +import { Plugin } from 'unified'; +// eslint-disable-next-line import/no-unresolved +import { Node, Parent } from 'unist'; + +interface TextNode extends Node { + type: 'text'; + value: string; +} + +interface EmphasisOrStrong extends Parent { + type: 'emphasis' | 'strong'; +} + +const isTextNode = (node: Node): node is TextNode => node.type === 'text'; + +const isEmphasisOrStrong = (node: Node): node is EmphasisOrStrong => + node.type === 'emphasis' || node.type === 'strong'; + +const isAlphanumeric = (ch: string): boolean => /[a-zA-Z0-9]/.test(ch); + +/** + * Recursively converts an emphasis/strong node back into plain text + * with underscore delimiters restored. + */ +function flattenToText(node: EmphasisOrStrong): string { + const delimiter = node.type === 'emphasis' ? '_' : '__'; + let inner = ''; + for (const child of node.children) { + if (isTextNode(child)) { + inner += child.value; + } else if (isEmphasisOrStrong(child)) { + inner += flattenToText(child); + } else { + // Contains non-text children (links, images, etc.) β€” leave emphasis intact + return delimiter + collectText(node) + delimiter; + } + } + return delimiter + inner + delimiter; +} + +function collectText(node: Node): string { + if (isTextNode(node)) return node.value; + if ('children' in node) { + return (node as Parent).children.map(collectText).join(''); + } + return ''; +} + +function processParent(parent: Parent, source: string) { + let modified = false; + let i = 0; + + while (i < parent.children.length) { + const child = parent.children[i]; + + if (isEmphasisOrStrong(child) && isIntraword(parent, i, source)) { + const textValue = flattenToText(child); + const replacement: TextNode = { + type: 'text', + value: textValue, + } as TextNode; + + parent.children.splice(i, 1, replacement); + modified = true; + // Don't advance β€” the replaced node may need to merge with neighbors + } else { + if ('children' in child) { + processParent(child as Parent, source); + } + i++; + } + + if (modified) { + mergeAdjacentText(parent); + modified = false; + // After merging, restart scan since indices shifted + i = 0; + } + } +} + +function getInnerText(node: Node): string { + if (isTextNode(node)) return node.value; + if ('children' in node) { + const children = (node as Parent).children; + if (children.length > 0) return getInnerText(children[0]); + } + return ''; +} + +function getInnerTextEnd(node: Node): string { + if (isTextNode(node)) return node.value; + if ('children' in node) { + const children = (node as Parent).children; + if (children.length > 0) + return getInnerTextEnd(children[children.length - 1]); + } + return ''; +} + +/** + * Checks whether the emphasis/strong node at `index` within `parent` + * is an intraword usage of underscore delimiters. + * + * A node is intraword when at least one side has an alphanumeric text + * neighbor AND the inner content on the corresponding delimiter side + * also touches word characters β€” proving the underscores are embedded + * in a word rather than used as formatting. + */ +function isIntraword(parent: Parent, index: number, source: string): boolean { + const node = parent.children[index]; + + // Verify the delimiter is `_` (not `*`) by inspecting the source + if (node.position?.start?.offset != null) { + const ch = source[node.position.start.offset]; + if (ch !== '_') return false; + } + + const prev = index > 0 ? parent.children[index - 1] : null; + const next = + index < parent.children.length - 1 ? parent.children[index + 1] : null; + + const prevChar = + prev != null && isTextNode(prev) && prev.value.length > 0 + ? prev.value[prev.value.length - 1] + : null; + + const nextChar = + next != null && isTextNode(next) && next.value.length > 0 + ? next.value[0] + : null; + + const prevIsAlpha = prevChar != null && isAlphanumeric(prevChar); + const nextIsAlpha = nextChar != null && isAlphanumeric(nextChar); + + // Both sides flanked by alphanumeric β€” classic intraword (e.g. `foo__bar__baz`) + if (prevIsAlpha && nextIsAlpha) return true; + + // One-sided: prev is alpha or underscore, no alpha next β€” check inner text + // starts with alpha (e.g. `Lorem__ipsum__` or `Lorem__ipsum_`) + if (prevIsAlpha || prevChar === '_') { + const inner = getInnerText(node); + if (inner.length > 0 && isAlphanumeric(inner[0])) return true; + } + + // One-sided: next is alpha or underscore, no alpha prev β€” check inner text + // ends with alpha (e.g. `__Lorem__ipsum` or `_Lorem__ipsum`) + if (nextIsAlpha || nextChar === '_') { + const inner = getInnerTextEnd(node); + if (inner.length > 0 && isAlphanumeric(inner[inner.length - 1])) + return true; + } + + return false; +} + +function mergeAdjacentText(parent: Parent) { + let i = 0; + while (i < parent.children.length - 1) { + if (isTextNode(parent.children[i]) && isTextNode(parent.children[i + 1])) { + (parent.children[i] as TextNode).value += ( + parent.children[i + 1] as TextNode + ).value; + parent.children.splice(i + 1, 1); + } else { + i++; + } + } +} + +const attacher: Plugin = function remarkIntrawordUnderscore() { + return (tree, file) => { + const source = String(file); + processParent(tree as Parent, source); + }; +}; + +export default attacher; diff --git a/packages/eui/src/components/popover/wrapping_popover.tsx b/packages/eui/src/components/popover/wrapping_popover.tsx index 588401b3ae6a..ed6651401d7e 100644 --- a/packages/eui/src/components/popover/wrapping_popover.tsx +++ b/packages/eui/src/components/popover/wrapping_popover.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { Component } from 'react'; +import React, { useCallback, useLayoutEffect, useRef } from 'react'; import { EuiPopover, Props as EuiPopoverProps } from './popover'; import { EuiPortal } from '../portal'; @@ -20,43 +20,42 @@ export interface EuiWrappingPopoverProps * then the button element is moved into the popover dom. * On unmount, the button is moved back to its original location. */ -export class EuiWrappingPopover extends Component { - private portal: HTMLElement | null = null; - componentWillUnmount() { - if (this.props.button.parentNode) { - if (this.portal) { - this.portal.insertAdjacentElement('beforebegin', this.props.button); +export function EuiWrappingPopover(props: EuiWrappingPopoverProps) { + const { button, ...rest } = props; + + const portalRef = useRef(null); + + const setPortalRef = useCallback((node: HTMLElement | null) => { + portalRef.current = node; + }, []); + + const setAnchorRef = useCallback( + (node: HTMLDivElement | null) => { + node?.insertAdjacentElement('beforebegin', button); + }, + [button] + ); + + useLayoutEffect(() => { + return () => { + if (button.parentNode && portalRef.current) { + portalRef.current.insertAdjacentElement('beforebegin', button); } - } - } - - setPortalRef = (node: HTMLElement | null) => { - this.portal = node; - }; - - setAnchorRef = (node: HTMLElement | null) => { - node?.insertAdjacentElement('beforebegin', this.props.button); - }; - - render() { - const { button, ...rest } = this.props; - - return ( - - - } - /> - - ); - } + }; + }, [button]); + + return ( + + + } + /> + + ); } diff --git a/packages/eui/src/components/search_bar/filters/field_value_toggle_filter.tsx b/packages/eui/src/components/search_bar/filters/field_value_toggle_filter.tsx index dd8e5c006b7d..fcd9ffd6a0f5 100644 --- a/packages/eui/src/components/search_bar/filters/field_value_toggle_filter.tsx +++ b/packages/eui/src/components/search_bar/filters/field_value_toggle_filter.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { Component } from 'react'; +import React, { FC } from 'react'; import { EuiFilterButton } from '../../filter_group'; import { isNil } from '../../../services/predicate'; import { Query } from '../query'; @@ -29,9 +29,11 @@ export interface FieldValueToggleFilterProps { onChange: (value: Query) => void; } -export class FieldValueToggleFilter extends Component { - resolveDisplay(clause: Clause | undefined) { - const { name, negatedName } = this.props.config; +export const FieldValueToggleFilter: FC = ( + props +) => { + const resolveDisplay = (clause: Clause | undefined) => { + const { name, negatedName } = props.config; if (isNil(clause)) { return { hasActiveFilters: false, name }; } @@ -41,34 +43,33 @@ export class FieldValueToggleFilter extends Component { + const { field, value, operator } = props.config; const query = checked - ? this.props.query.removeSimpleFieldValue(field, value) - : this.props.query.addSimpleFieldValue(field, value, true, operator); - this.props.onChange(query); - } + ? props.query.removeSimpleFieldValue(field, value) + : props.query.addSimpleFieldValue(field, value, true, operator); + props.onChange(query); + }; - render() { - const { query, config } = this.props; - const clause = query.getSimpleFieldClause(config.field, config.value); - const checked = !isNil(clause); - const { hasActiveFilters, name } = this.resolveDisplay(clause); - const onClick = () => { - this.valueChanged(checked); - }; - return ( - - {name} - - ); - } -} + const { query, config } = props; + const clause = query.getSimpleFieldClause(config.field, config.value); + const checked = !isNil(clause); + const { hasActiveFilters, name } = resolveDisplay(clause); + const onClick = () => { + valueChanged(checked); + }; + + return ( + + {name} + + ); +}; diff --git a/packages/eui/src/components/search_bar/search_filters.tsx b/packages/eui/src/components/search_bar/search_filters.tsx index 3b66d4228c23..575534ae0279 100644 --- a/packages/eui/src/components/search_bar/search_filters.tsx +++ b/packages/eui/src/components/search_bar/search_filters.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { Component, Fragment, ReactElement } from 'react'; +import React, { Fragment, FunctionComponent, ReactElement } from 'react'; import { createFilter, SearchFilterConfig } from './filters'; import { Query } from './query'; import { EuiFilterGroup } from '../filter_group'; @@ -19,27 +19,18 @@ export interface EuiSearchBarFiltersProps { filters: SearchFilterConfig[]; } -type DefaultProps = Pick; - -export class EuiSearchBarFilters extends Component { - static defaultProps: DefaultProps = { - filters: [], - }; - - render() { - const { filters = [], query, onChange } = this.props; - - const items: ReactElement[] = []; - - filters.forEach((filterConfig, index) => { - if (filterConfig.available && !filterConfig.available()) { - return; - } - const key = `filter_${index}`; - const control = createFilter(index, filterConfig, query, onChange); - items.push({control}); - }); - - return {items}; - } -} +export const EuiSearchBarFilters: FunctionComponent< + EuiSearchBarFiltersProps +> = ({ filters = [], query, onChange }) => { + const items = filters.reduce((acc, filterConfig, index) => { + if (filterConfig.available && !filterConfig.available()) { + return acc; + } + const key = `filter_${index}`; + const control = createFilter(index, filterConfig, query, onChange); + acc.push({control}); + return acc; + }, []); + + return {items}; +}; diff --git a/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap b/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap index 68e2c598f113..7b0d7c5b20be 100644 --- a/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap +++ b/packages/eui/src/components/selectable/selectable_list/__snapshots__/selectable_list_item.test.tsx.snap @@ -547,7 +547,6 @@ exports[`EuiSelectableListItem props tooltip behavior on mouseover 1`] = ` data-position="top" data-test-subj="listItemToolTip" id="generated-id" - position="top" role="tooltip" style="top: -16px; left: -10px;" > @@ -628,7 +627,6 @@ exports[`EuiSelectableListItem props tooltip behavior when isFocused 1`] = ` data-position="top" data-test-subj="listItemToolTip" id="generated-id" - position="top" role="tooltip" style="top: -16px; left: -10px;" > diff --git a/packages/eui/src/components/selectable/selectable_list/selectable_list_item.test.tsx b/packages/eui/src/components/selectable/selectable_list/selectable_list_item.test.tsx index 7b863db6b515..8c841a1eb0e4 100644 --- a/packages/eui/src/components/selectable/selectable_list/selectable_list_item.test.tsx +++ b/packages/eui/src/components/selectable/selectable_list/selectable_list_item.test.tsx @@ -174,7 +174,6 @@ describe('EuiSelectableListItem', () => { toolTipProps={{ title: 'Test', position: 'top', - delay: 'long', 'data-test-subj': 'listItemToolTip', }} > @@ -197,7 +196,6 @@ describe('EuiSelectableListItem', () => { toolTipProps={{ title: 'Test', position: 'top', - delay: 'long', 'data-test-subj': 'listItemToolTip', }} isFocused diff --git a/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx b/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx index b2aa9e497ce1..77c21b2beb1e 100644 --- a/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx +++ b/packages/eui/src/components/selectable/selectable_list/selectable_list_item.tsx @@ -23,6 +23,7 @@ import { EuiIcon, IconColor, IconType } from '../../icon'; import { EuiScreenReaderOnly } from '../../accessibility'; import { EuiBadge, EuiBadgeProps } from '../../badge'; import { EuiToolTip } from '../../tool_tip'; +import type { EuiToolTipRef } from '../../tool_tip'; import type { EuiSelectableOption, @@ -358,7 +359,7 @@ export const EuiSelectableListItem: FunctionComponent< }, [role, checked]); const hasToolTip = !!toolTipContent && !disabled; - const [tooltipRef, setTooltipRef] = useState(null); // Needs to be state and not a ref to trigger useEffect + const [tooltipRef, setTooltipRef] = useState(null); // Needs to be state and not a ref to trigger useEffect const [ariaDescribedBy, setAriaDescribedBy] = useState(_ariaDescribedBy); // Manually trigger the tooltip on keyboard focus @@ -375,7 +376,7 @@ export const EuiSelectableListItem: FunctionComponent< // Manually set the `aria-describedby` id on the
  • wrapper useEffect(() => { if (tooltipRef) { - const tooltipId = tooltipRef.state.id; + const tooltipId = tooltipRef.id; setAriaDescribedBy(classNames(tooltipId, _ariaDescribedBy)); } }, [tooltipRef, _ariaDescribedBy]); diff --git a/packages/eui/src/components/table/mobile/table_header_mobile.tsx b/packages/eui/src/components/table/mobile/table_header_mobile.tsx index 26a512357795..7d579901d547 100644 --- a/packages/eui/src/components/table/mobile/table_header_mobile.tsx +++ b/packages/eui/src/components/table/mobile/table_header_mobile.tsx @@ -30,7 +30,7 @@ export const EuiTableHeaderMobile: FunctionComponent< const responsiveBreakpointDefault = useComponentDefaults().EuiTable?.responsiveBreakpoint; const responsiveBreakpoint = - responsiveBreakpointProp || responsiveBreakpointDefault; + responsiveBreakpointProp ?? responsiveBreakpointDefault; const isResponsive = useIsEuiTableResponsive(responsiveBreakpoint); const styles = useEuiMemoizedStyles(euiTableHeaderMobileStyles); diff --git a/packages/eui/src/components/table/table_footer_cell.test.tsx b/packages/eui/src/components/table/table_footer_cell.test.tsx index d9ad3149c531..ce97e39a7788 100644 --- a/packages/eui/src/components/table/table_footer_cell.test.tsx +++ b/packages/eui/src/components/table/table_footer_cell.test.tsx @@ -18,6 +18,7 @@ import { WARNING_MESSAGE_MAX_WIDTH, WARNING_MESSAGE_MIN_WIDTH, WARNING_MESSAGE_WIDTH, + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT, } from './utils'; import type { EuiTableSharedWidthProps } from './types'; @@ -76,14 +77,14 @@ describe('EuiTableFooterCell', () => { it('accepts `style` prop', () => { const { getByRole } = renderInTableFooter( Test ); expect(getByRole('cell')).toHaveStyle({ - width: '20%', + width: '200px', minWidth: '123px', maxWidth: '456px', }); @@ -99,14 +100,14 @@ describe('EuiTableFooterCell', () => { it(`accepts \`${name}\` prop`, () => { const { getByRole } = renderInTableFooter( - + Test ); expect(getByRole('cell')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); }); @@ -130,9 +131,9 @@ describe('EuiTableFooterCell', () => { console.warn = jest.fn(); const props = { - [name]: '10%', + [name]: '100px', style: { - [name]: '20%', + [name]: '200px', }, }; @@ -142,13 +143,28 @@ describe('EuiTableFooterCell', () => { expect(getByRole('cell')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); expect(console.warn).toHaveBeenCalledWith(warningMessage); console.warn = originalConsoleWarn; }); + + it('warns when a not recommended unit is used', () => { + const originalConsoleWarn = console.warn; + console.warn = jest.fn(); + + renderInTableFooter( + Test + ); + + expect(console.warn).toHaveBeenCalledWith( + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT + ); + + console.warn = originalConsoleWarn; + }); }; describe('width', testProp('width', WARNING_MESSAGE_WIDTH)); diff --git a/packages/eui/src/components/table/table_header_cell.test.tsx b/packages/eui/src/components/table/table_header_cell.test.tsx index 08fcef7f2af9..05e0cae93ea3 100644 --- a/packages/eui/src/components/table/table_header_cell.test.tsx +++ b/packages/eui/src/components/table/table_header_cell.test.tsx @@ -16,6 +16,7 @@ import { WARNING_MESSAGE_MAX_WIDTH, WARNING_MESSAGE_MIN_WIDTH, WARNING_MESSAGE_WIDTH, + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT, } from './utils'; import { EuiTableIsResponsiveContext } from './mobile/responsive_context'; @@ -158,14 +159,14 @@ describe('EuiTableHeaderCell', () => { it('accepts `style` prop', () => { const { getByRole } = renderInTableHeader( Test ); expect(getByRole('columnheader')).toHaveStyle({ - width: '20%', + width: '200px', minWidth: '123px', maxWidth: '456px', }); @@ -181,14 +182,14 @@ describe('EuiTableHeaderCell', () => { it(`accepts \`${name}\` prop`, () => { const { getByRole } = renderInTableHeader( - + Test ); expect(getByRole('columnheader')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); }); @@ -212,9 +213,9 @@ describe('EuiTableHeaderCell', () => { console.warn = jest.fn(); const props = { - [name]: '10%', + [name]: '100px', style: { - [name]: '20%', + [name]: '200px', }, }; @@ -224,13 +225,28 @@ describe('EuiTableHeaderCell', () => { expect(getByRole('columnheader')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); expect(console.warn).toHaveBeenCalledWith(warningMessage); console.warn = originalConsoleWarn; }); + + it('warns when a not recommended unit is used', () => { + const originalConsoleWarn = console.warn; + console.warn = jest.fn(); + + renderInTableHeader( + Test + ); + + expect(console.warn).toHaveBeenCalledWith( + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT + ); + + console.warn = originalConsoleWarn; + }); }; describe('width', testProp('width', WARNING_MESSAGE_WIDTH)); diff --git a/packages/eui/src/components/table/table_header_cell_checkbox.test.tsx b/packages/eui/src/components/table/table_header_cell_checkbox.test.tsx index 821fd12f215a..99decf3c5848 100644 --- a/packages/eui/src/components/table/table_header_cell_checkbox.test.tsx +++ b/packages/eui/src/components/table/table_header_cell_checkbox.test.tsx @@ -15,6 +15,7 @@ import { WARNING_MESSAGE_MAX_WIDTH, WARNING_MESSAGE_MIN_WIDTH, WARNING_MESSAGE_WIDTH, + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT, } from './utils'; import type { EuiTableSharedWidthProps } from './types'; @@ -40,14 +41,14 @@ describe('EuiTableHeaderCellCheckbox', () => { it('accepts `style` prop', () => { const { getByRole } = renderInTableHeader( Test ); expect(getByRole('columnheader')).toHaveStyle({ - width: '20%', + width: '200px', minWidth: '123px', maxWidth: '456px', }); @@ -63,14 +64,14 @@ describe('EuiTableHeaderCellCheckbox', () => { it(`accepts \`${name}\` prop`, () => { const { getByRole } = renderInTableHeader( - + Test ); expect(getByRole('columnheader')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); }); @@ -96,9 +97,9 @@ describe('EuiTableHeaderCellCheckbox', () => { console.warn = jest.fn(); const props = { - [name]: '10%', + [name]: '100px', style: { - [name]: '20%', + [name]: '200px', }, }; @@ -110,13 +111,30 @@ describe('EuiTableHeaderCellCheckbox', () => { expect(getByRole('columnheader')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); expect(console.warn).toHaveBeenCalledWith(warningMessage); console.warn = originalConsoleWarn; }); + + it('warns when a not recommended unit is used', () => { + const originalConsoleWarn = console.warn; + console.warn = jest.fn(); + + renderInTableHeader( + + Test + + ); + + expect(console.warn).toHaveBeenCalledWith( + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT + ); + + console.warn = originalConsoleWarn; + }); }; describe('width', testProp('width', WARNING_MESSAGE_WIDTH)); diff --git a/packages/eui/src/components/table/table_row_cell.test.tsx b/packages/eui/src/components/table/table_row_cell.test.tsx index 0f64a8909012..213e3c2e3a9e 100644 --- a/packages/eui/src/components/table/table_row_cell.test.tsx +++ b/packages/eui/src/components/table/table_row_cell.test.tsx @@ -15,6 +15,7 @@ import { WARNING_MESSAGE_MAX_WIDTH, WARNING_MESSAGE_MIN_WIDTH, WARNING_MESSAGE_WIDTH, + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT, } from './utils'; import { EuiTableIsResponsiveContext } from './mobile/responsive_context'; @@ -202,14 +203,14 @@ describe('EuiTableRowCell', () => { it('accepts `style` prop', () => { const { getByRole } = renderInTableRow( Test ); expect(getByRole('cell')).toHaveStyle({ - width: '20%', + width: '200px', minWidth: '123px', maxWidth: '456px', }); @@ -225,12 +226,12 @@ describe('EuiTableRowCell', () => { it(`accepts \`${name}\` prop`, () => { const { getByRole } = renderInTableRow( - Test + Test ); expect(getByRole('cell')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); }); @@ -254,9 +255,9 @@ describe('EuiTableRowCell', () => { console.warn = jest.fn(); const props = { - [name]: '10%', + [name]: '100px', style: { - [name]: '20%', + [name]: '200px', }, }; @@ -266,13 +267,28 @@ describe('EuiTableRowCell', () => { expect(getByRole('cell')).toHaveStyle({ ...defaultStyles, - [name]: '10%', + [name]: '100px', }); expect(console.warn).toHaveBeenCalledWith(warningMessage); console.warn = originalConsoleWarn; }); + + it('warns when a not recommended unit is used', () => { + const originalConsoleWarn = console.warn; + console.warn = jest.fn(); + + renderInTableRow( + Test + ); + + expect(console.warn).toHaveBeenCalledWith( + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT + ); + + console.warn = originalConsoleWarn; + }); }; describe('width', testProp('width', WARNING_MESSAGE_WIDTH)); diff --git a/packages/eui/src/components/table/utils.test.ts b/packages/eui/src/components/table/utils.test.ts index 635981dde505..d848a2b5c0da 100644 --- a/packages/eui/src/components/table/utils.test.ts +++ b/packages/eui/src/components/table/utils.test.ts @@ -11,26 +11,34 @@ import { WARNING_MESSAGE_MAX_WIDTH, WARNING_MESSAGE_MIN_WIDTH, WARNING_MESSAGE_WIDTH, + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT, } from './utils'; describe('resolveWidthPropsAsStyle', () => { + let originalConsoleWarn: typeof console.warn; + + beforeAll(() => { + originalConsoleWarn = console.warn; + console.warn = jest.fn(); + }); + + afterAll(() => { + console.warn = originalConsoleWarn; + }); + it('returns an empty style object when no style or width props are provided', () => { expect(resolveWidthPropsAsStyle(undefined, {})).toEqual({}); expect(resolveWidthPropsAsStyle({}, {})).toEqual({}); }); - const testPropAndStyle = (name: string, warningMessage: string) => () => { - let originalConsoleWarn: typeof console.warn; - - beforeAll(() => { - originalConsoleWarn = console.warn; - console.warn = jest.fn(); - }); - - afterAll(() => { - console.warn = originalConsoleWarn; - }); + it('warns when a not recommended unit is used', () => { + expect(resolveWidthPropsAsStyle({}, { width: '10%' })); + expect(console.warn).toHaveBeenCalledWith( + WARNING_MESSAGE_NOT_RECOMMENDED_UNIT + ); + }); + const testPropAndStyle = (name: string, warningMessage: string) => () => { it(`supports setting ${name} via the prop`, () => { expect(resolveWidthPropsAsStyle({}, { [name]: '123px' })).toEqual({ [name]: '123px', diff --git a/packages/eui/src/components/table/utils.ts b/packages/eui/src/components/table/utils.ts index 03efd934e96c..b6447bfcf147 100644 --- a/packages/eui/src/components/table/utils.ts +++ b/packages/eui/src/components/table/utils.ts @@ -27,6 +27,12 @@ export const WARNING_MESSAGE_MIN_WIDTH = export const WARNING_MESSAGE_MAX_WIDTH = 'Two `maxWidth` properties were provided. Provide only one of `style.maxWidth` or `maxWidth` to avoid conflicts.'; +/** + * @internal + */ +export const WARNING_MESSAGE_NOT_RECOMMENDED_UNIT = + 'Detected not recommended unit (%, vw, cqw, cqi) in cell width settings. Adjust the `width`, `minWidth` and `maxWidth` values to use absolute length units like `em` for text cells or `px` for static elements like icons or plots.'; + const normalizeValue = ( value: string | number | undefined ): string | undefined => { @@ -41,6 +47,18 @@ const normalizeValue = ( return value; }; +const UNIT_VALIDATOR_REGEX = /%|vw|cqw|cqi/; + +const shouldWarnAboutNotRecommendedUnit = ( + value: string | number | undefined +): boolean => { + if (typeof value === 'string') { + return UNIT_VALIDATOR_REGEX.test(value); + } + + return false; +}; + /** * @internal */ @@ -52,27 +70,40 @@ export const resolveWidthPropsAsStyle = ( maxWidth: rawMaxWidth, }: EuiTableSharedWidthProps ): CSSProperties => { - const width = normalizeValue(rawWidth); - const minWidth = normalizeValue(rawMinWidth); - const maxWidth = normalizeValue(rawMaxWidth); + const widthProp = normalizeValue(rawWidth); + const minWidthProp = normalizeValue(rawMinWidth); + const maxWidthProp = normalizeValue(rawMaxWidth); + const width = widthProp ?? style.width; + const minWidth = minWidthProp ?? style.minWidth; + const maxWidth = maxWidthProp ?? style.maxWidth; + + // Value validation block if (process.env.NODE_ENV !== 'production') { - if (style.width && width !== undefined) { + if (style.width && widthProp !== undefined) { console.warn(WARNING_MESSAGE_WIDTH); } - if (style.minWidth && minWidth !== undefined) { + if (style.minWidth && minWidthProp !== undefined) { console.warn(WARNING_MESSAGE_MIN_WIDTH); } - if (style.maxWidth && maxWidth !== undefined) { + if (style.maxWidth && maxWidthProp !== undefined) { console.warn(WARNING_MESSAGE_MAX_WIDTH); } + + if ( + shouldWarnAboutNotRecommendedUnit(width) || + shouldWarnAboutNotRecommendedUnit(minWidth) || + shouldWarnAboutNotRecommendedUnit(maxWidth) + ) { + console.warn(WARNING_MESSAGE_NOT_RECOMMENDED_UNIT); + } } return { - width: width || style.width, - minWidth: minWidth || style.minWidth, - maxWidth: maxWidth || style.maxWidth, + width, + minWidth, + maxWidth, }; }; diff --git a/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap b/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap index f7dfa157febb..2729fa5b0a2d 100644 --- a/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap +++ b/packages/eui/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap @@ -13,46 +13,3 @@ exports[`EuiIconTip is rendered 1`] = ` `; - -exports[`EuiIconTip props color is rendered as the icon color 1`] = ` - - - Info - - -`; - -exports[`EuiIconTip props size is rendered as the icon size 1`] = ` - - - Info - - -`; - -exports[`EuiIconTip props type is rendered as the icon 1`] = ` - - - Info - - -`; diff --git a/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap b/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap index 3686e08ae943..db8eb066621c 100644 --- a/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap +++ b/packages/eui/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap @@ -1,36 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`EuiToolTip anchor props are rendered 1`] = ` - -
    - - - -
    - -`; - -exports[`EuiToolTip display prop renders block 1`] = ` -
    - - - -
    -`; - exports[`EuiToolTip is rendered 1`] = ` `; - -exports[`EuiToolTip shows tooltip on mouseover and focus 1`] = ` - -
    - - - -
    -
    - - -`; - -exports[`EuiToolTip uses custom offset prop value 1`] = ` - -
    - - - -
    -
    - - -`; diff --git a/packages/eui/src/components/tool_tip/icon_tip.stories.tsx b/packages/eui/src/components/tool_tip/icon_tip.stories.tsx index c03db00ff451..b591e4c43986 100644 --- a/packages/eui/src/components/tool_tip/icon_tip.stories.tsx +++ b/packages/eui/src/components/tool_tip/icon_tip.stories.tsx @@ -15,7 +15,6 @@ import { } from '../../../.storybook/utils'; import { LOKI_SELECTORS } from '../../../.storybook/loki'; import { EuiFlexGroup } from '../flex'; -import { ToolTipDelay } from './tool_tip'; import { EuiIconTip, EuiIconTipProps } from './icon_tip'; const meta: Meta = { @@ -43,7 +42,6 @@ const meta: Meta = { args: { type: 'question', position: 'top', - delay: 'regular', display: 'inlineBlock', // set up for easier testing/QA anchorClassName: '', @@ -79,6 +77,5 @@ export const Playground: Story = { // @ts-ignore - temp. solution for storybook VRT testing autofocus: 'true', }, - delay: 'none' as ToolTipDelay, // passing a (not-yet) supported value to hackishly force a lower delay for VRT }, }; diff --git a/packages/eui/src/components/tool_tip/icon_tip.test.tsx b/packages/eui/src/components/tool_tip/icon_tip.test.tsx index 9ef82589c2b5..e6b32e575572 100644 --- a/packages/eui/src/components/tool_tip/icon_tip.test.tsx +++ b/packages/eui/src/components/tool_tip/icon_tip.test.tsx @@ -26,7 +26,7 @@ describe('EuiIconTip', () => { } ); - test('is rendered', () => { + it('is rendered', () => { const { container } = render( ); @@ -35,34 +35,67 @@ describe('EuiIconTip', () => { }); describe('props', () => { - describe('type', () => { - test('is rendered as the icon', () => { - const { container } = render( - - ); - - expect(container.firstChild).toMatchSnapshot(); - }); + it('renders a different icon for each type', () => { + const { container: defaultContainer } = render( + + ); + const { container: warningContainer } = render( + + ); + + expect(defaultContainer.innerHTML).not.toEqual( + warningContainer.innerHTML + ); + }); + + it('renders a different icon for each color', () => { + const { container: defaultContainer } = render( + + ); + const { container: colorContainer } = render( + + ); + + expect(defaultContainer.innerHTML).not.toEqual(colorContainer.innerHTML); + }); + + it('accepts a size prop without errors', () => { + // Size is applied via CSS and has no observable DOM difference in the test environment + expect(() => + render() + ).not.toThrow(); }); + }); - describe('color', () => { - test('is rendered as the icon color', () => { - const { container } = render( - - ); + describe('aria-label', () => { + // In the test environment EuiIcon renders aria-label as its text content + it('uses "Info" as the default aria-label', () => { + const { container } = render(); - expect(container.firstChild).toMatchSnapshot(); - }); + expect(container.querySelector('[data-euiicon-type]')).toHaveTextContent( + 'Info' + ); }); - describe('size', () => { - test('is rendered as the icon size', () => { - const { container } = render( - - ); + it('uses a custom aria-label when provided', () => { + const { container } = render( + + ); - expect(container.firstChild).toMatchSnapshot(); - }); + expect(container.querySelector('[data-euiicon-type]')).toHaveTextContent( + 'More information' + ); }); }); + + it('shows tooltip content on hover', async () => { + const { container, getByRole } = render( + + ); + + fireEvent.mouseOver(container.querySelector('[data-euiicon-type]')!); + await waitForEuiToolTipVisible(); + + expect(getByRole('tooltip')).toHaveTextContent('Tooltip content'); + }); }); diff --git a/packages/eui/src/components/tool_tip/icon_tip.tsx b/packages/eui/src/components/tool_tip/icon_tip.tsx index 19554f7c6db7..5761b8091b38 100644 --- a/packages/eui/src/components/tool_tip/icon_tip.tsx +++ b/packages/eui/src/components/tool_tip/icon_tip.tsx @@ -15,7 +15,7 @@ import { EuiToolTip, EuiToolTipProps } from './tool_tip'; export type EuiIconTipProps = Omit< EuiToolTipProps, - 'children' | 'delay' | 'position' + 'children' | 'position' > & { /** * Children are not allowed as they are built using the icon props @@ -44,9 +44,8 @@ export type EuiIconTipProps = Omit< // iconProps; however, due to TS's bivariant function arguments `type` could be // passed without any error/feedback so we explicitly set it to `never` type iconProps?: Omit, 'type'> & { type?: never }; - // This are copied from EuiToolTipProps, but made optional. Defaults - // are applied below. - delay?: EuiToolTipProps['delay']; + // This is copied from EuiToolTipProps, but made optional. Default + // is applied below. position?: EuiToolTipProps['position']; }; @@ -57,13 +56,12 @@ export const EuiIconTip: FunctionComponent = ({ size, iconProps, position = 'top', - delay = 'regular', ...rest }) => { const defaultAriaLabel = useEuiI18n('euiIconTip.defaultAriaLabel', 'Info'); return ( - + = { ], args: { position: 'top', - delay: 'regular', display: 'inlineBlock', // set up for easier testing/QA anchorClassName: '', diff --git a/packages/eui/src/components/tool_tip/tool_tip.test.tsx b/packages/eui/src/components/tool_tip/tool_tip.test.tsx index 9b401fa5ead5..9bcdcd31b9be 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.test.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.test.tsx @@ -6,9 +6,8 @@ * Side Public License, v 1. */ -import React, { useRef } from 'react'; +import React, { createRef, useRef } from 'react'; import { fireEvent } from '@testing-library/react'; -import { userEvent } from '@storybook/test'; import { render, waitForEuiToolTipVisible, @@ -18,6 +17,7 @@ import { requiredProps } from '../../test'; import { shouldRenderCustomStyles } from '../../test/internal'; import { EuiToolTip } from './tool_tip'; +import type { EuiToolTipRef } from './tool_tip'; describe('EuiToolTip', () => { shouldRenderCustomStyles( @@ -43,72 +43,145 @@ describe('EuiToolTip', () => { expect(baseElement).toMatchSnapshot(); }); - it('shows tooltip on mouseover and focus', async () => { - const { baseElement, getByTestSubject } = render( - - - - ); + describe('visibility', () => { + it('shows on mouseover and hides on mouseout', async () => { + const { getByTestSubject, queryByRole } = render( + + + + ); - fireEvent.mouseOver(getByTestSubject('trigger')); - await waitForEuiToolTipVisible(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); - expect(baseElement).toMatchSnapshot(); + fireEvent.mouseOver(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + expect(queryByRole('tooltip')).toBeInTheDocument(); - fireEvent.mouseOut(getByTestSubject('trigger')); - await waitForEuiToolTipHidden(); + fireEvent.mouseOut(getByTestSubject('trigger')); + await waitForEuiToolTipHidden(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); - fireEvent.focus(getByTestSubject('trigger')); - await waitForEuiToolTipVisible(); - }); + it('shows on focus and hides on blur', async () => { + const { getByTestSubject, queryByRole } = render( + + + + ); - it('uses custom offset prop value', async () => { - const offsetValue = 32; - const { baseElement, getByRole } = render( - - - - ); - const trigger = getByRole('button'); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); - await userEvent.hover(trigger); - await waitForEuiToolTipVisible(); - expect(baseElement).toMatchSnapshot(); - }); + fireEvent.focus(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + expect(queryByRole('tooltip')).toBeInTheDocument(); - test('anchor props are rendered', () => { - const { baseElement } = render( - - - - ); + fireEvent.blur(getByTestSubject('trigger')); + await waitForEuiToolTipHidden(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); - expect(baseElement).toMatchSnapshot(); + it('keeps tooltip visible on mouseout when the trigger has focus', async () => { + const { getByTestSubject, queryByRole } = render( + + + + ); + + fireEvent.focus(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + + fireEvent.mouseOut(getByTestSubject('trigger')); + // Tooltip stays visible because hasFocus=true + expect(queryByRole('tooltip')).toBeInTheDocument(); + }); + + it('does not render when neither content nor title are provided', () => { + const { queryByRole, getByTestSubject } = render( + + + + ); + + fireEvent.mouseOver(getByTestSubject('trigger')); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('renders with title only and no content', async () => { + const { getByTestSubject, getByRole } = render( + + + + ); + + fireEvent.mouseOver(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + + expect(getByRole('tooltip')).toHaveTextContent('Tooltip title'); + }); }); - test('display prop renders block', () => { - const { container } = render( - - - - ); + describe('props', () => { + it('applies anchorClassName and anchorProps to the anchor wrapper', () => { + const { container } = render( + + + + ); + + const anchor = container.querySelector('.euiToolTipAnchor'); + expect(anchor).toHaveClass('customAnchorClass'); + expect(anchor).toHaveAttribute('data-test-subj', 'anchor'); + }); + + it('display="block" applies a different CSS class than display="inlineBlock"', () => { + const { container: blockContainer } = render( + + + + ); + const { container: inlineBlockContainer } = render( + + + + ); + + const blockAnchor = blockContainer.querySelector('.euiToolTipAnchor')!; + const inlineBlockAnchor = + inlineBlockContainer.querySelector('.euiToolTipAnchor')!; + expect(blockAnchor.className).not.toEqual(inlineBlockAnchor.className); + }); + + it('calls the onMouseOut prop callback on mouseout', async () => { + const onMouseOut = jest.fn(); + const { getByTestSubject } = render( + + + + ); + + fireEvent.mouseOver(getByRole('button')); + await waitForEuiToolTipVisible(); - expect(container).toMatchSnapshot(); + expect(document.querySelector('[role="tooltip"]')).toBeInTheDocument(); + }); }); describe('aria-describedby', () => { @@ -121,9 +194,31 @@ describe('EuiToolTip', () => { fireEvent.mouseOver(getByTestSubject('anchor')); await waitForEuiToolTipVisible(); - expect( - getByTestSubject('anchor').getAttribute('aria-describedby') - ).toEqual('toolTipId'); + expect(getByTestSubject('anchor')).toHaveAttribute( + 'aria-describedby', + 'toolTipId' + ); + }); + + it('removes `aria-describedby` when the tooltip is hidden', async () => { + const { getByTestSubject } = render( + +
    + ); - test('showToolTip', async () => { - const ConsumerToolTip = () => { - const toolTipRef = useRef(null); + fireEvent.focus(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); - const showToolTip = () => { - toolTipRef.current?.showToolTip(); - }; + fireEvent.keyDown(getByTestSubject('trigger'), { key: 'Escape' }); + await waitForEuiToolTipHidden(); - // NOTE: KEEP IN MIND THAT THIS IS BAD ACCESSIBILITY PRACTICE AND ONLY HERE FOR TESTING - // Because focus is on separate item from the tooltip, aria-describedby does not trigger - // and the tooltip contents are not read out to screen readers - return ( - <> - - Not focusable - - - - ); - }; - const { getByTestSubject } = render(); + expect(parentKeyDown).not.toHaveBeenCalled(); + }); + + it('when true, Escape does not stop event propagation', async () => { + const parentKeyDown = jest.fn(); + const { getByTestSubject } = render( +
    + +
    + ); - fireEvent.click(getByTestSubject('trigger')); + fireEvent.focus(getByTestSubject('trigger')); await waitForEuiToolTipVisible(); - }); - test('hideToolTip', async () => { - // Consumers appear to mostly want this after modal/flyout/focus trap close, when - // focus is returned to toggling buttons with a tooltip, & said tooltip blocks UI - // @see https://github.com/elastic/eui/issues/5883#issuecomment-1120908605 for example - const ConsumerToolTip = () => { - const toolTipRef = useRef(null); + fireEvent.keyDown(getByTestSubject('trigger'), { key: 'Escape' }); + await waitForEuiToolTipHidden(); - const hideToolTip = () => { - toolTipRef.current?.hideToolTip(); - }; + expect(parentKeyDown).toHaveBeenCalledTimes(1); + }); - return ( - <> - - - - - ); - }; - const { getByTestSubject } = render(); + it('when true, tooltip still renders visually', async () => { + const { getByTestSubject, getByRole } = render( + + + + ); + }; + const { getByTestSubject, getByRole } = render(); + + expect( + document.querySelector('.euiToolTipPopover') + ).not.toBeInTheDocument(); + + fireEvent.click(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + + expect(getByRole('tooltip')).toBeInTheDocument(); + }); + + test('hideToolTip', async () => { + // Consumers appear to mostly want this after modal/flyout/focus trap close, when + // focus is returned to toggling buttons with a tooltip, & said tooltip blocks UI + // @see https://github.com/elastic/eui/issues/5883#issuecomment-1120908605 for example + const ConsumerToolTip = () => { + const toolTipRef = useRef(null); + + const hideToolTip = () => { + toolTipRef.current?.hideToolTip(); + }; + + return ( + <> + + + + + ); + }; + const { getByTestSubject, queryByRole } = render(); + + fireEvent.mouseOver(getByTestSubject('trigger')); + await waitForEuiToolTipVisible(); + expect( + document.querySelector('.euiToolTipPopover') + ).toBeInTheDocument(); + + fireEvent.click(getByTestSubject('trigger')); + await waitForEuiToolTipHidden(); + + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + + describe('id', () => { + it('exposes the id prop value', () => { + const ref = createRef(); + render( + + + + ); + expect(ref.current?.id).toBe('custom-id'); + }); + + it('exposes a generated id when no id prop is provided', () => { + const ref = createRef(); + render( + + + + ); + expect(ref.current?.id).toBeTruthy(); + }); }); }); }); diff --git a/packages/eui/src/components/tool_tip/tool_tip.tsx b/packages/eui/src/components/tool_tip/tool_tip.tsx index 6655d9f368d9..32d5eff7db04 100644 --- a/packages/eui/src/components/tool_tip/tool_tip.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip.tsx @@ -7,8 +7,13 @@ */ import React, { - Component, - ContextType, + forwardRef, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState, ReactElement, ReactNode, MouseEvent as ReactMouseEvent, @@ -18,10 +23,7 @@ import classNames from 'classnames'; import { CommonProps } from '../common'; import { findPopoverPosition, htmlIdGenerator, keys } from '../../services'; -import { - createRepositionOnScroll, - type CreateRepositionOnScrollReturnType, -} from '../../services/popover/reposition_on_scroll'; +import { getRepositionOnScroll } from '../../services/popover/reposition_on_scroll'; import { type EuiPopoverPosition } from '../../services/popover'; import { enqueueStateChange } from '../../services/react'; import { EuiResizeObserver } from '../observer/resize_observer'; @@ -36,14 +38,8 @@ import { toolTipManager } from './tool_tip_manager'; export const POSITIONS = ['top', 'right', 'bottom', 'left'] as const; const DISPLAYS = ['inlineBlock', 'block'] as const; -export type ToolTipDelay = 'regular' | 'long'; export const DEFAULT_TOOLTIP_OFFSET = 16; -const delayToMsMap: { [key in ToolTipDelay]: number } = { - regular: 250, - long: 250 * 5, -}; - interface ToolTipStyles { top: number; left: number | 'auto'; @@ -90,10 +86,6 @@ export interface EuiToolTipProps extends CommonProps { * Common display alternatives for the anchor wrapper */ display?: (typeof DISPLAYS)[number]; - /** - * Delay before showing tooltip. Good for repeatable items. - */ - delay: ToolTipDelay; /** * An optional title for your tooltip. */ @@ -105,7 +97,7 @@ export interface EuiToolTipProps extends CommonProps { /** * Suggested position. If there is not enough room for it this will be changed. */ - position: ToolTipPositions; + position?: ToolTipPositions; /** * When `true`, the tooltip's position is re-calculated when the user * scrolls. This supports having fixed-position tooltip anchors. @@ -135,225 +127,227 @@ export interface EuiToolTipProps extends CommonProps { offset?: number; } -interface State { - visible: boolean; - hasFocus: boolean; - calculatedPosition: ToolTipPositions; - toolTipStyles: ToolTipStyles; - arrowStyles?: Record; +export interface EuiToolTipRef { + showToolTip: () => void; + hideToolTip: () => void; id: string; } -export class EuiToolTip extends Component { - static contextType = EuiComponentDefaultsContext; - declare context: ContextType; - private repositionOnScroll: CreateRepositionOnScrollReturnType; - - _isMounted = false; - anchor: null | HTMLElement = null; - popover: null | HTMLElement = null; - private timeoutId?: ReturnType; - - constructor(props: EuiToolTipProps) { - super(props); - this.state = { - visible: false, - hasFocus: false, - calculatedPosition: this.props.position, - toolTipStyles: DEFAULT_TOOLTIP_STYLES, - arrowStyles: undefined, - id: this.props.id || htmlIdGenerator()(), - }; - - this.repositionOnScroll = createRepositionOnScroll(() => ({ - repositionOnScroll: this.props.repositionOnScroll, - componentDefaults: this.context.EuiToolTip, - repositionFn: this.positionToolTip, - })); - } - - static defaultProps: Partial = { - position: 'top', - delay: 'regular', - display: 'inlineBlock', - disableScreenReaderOutput: false, - }; - - clearAnimationTimeout = () => { - if (this.timeoutId) { - this.timeoutId = clearTimeout(this.timeoutId) as undefined; - } - }; - - componentDidMount() { - this._isMounted = true; - this.repositionOnScroll.subscribe(); - } - - componentWillUnmount() { - this.clearAnimationTimeout(); - this._isMounted = false; - this.repositionOnScroll.cleanup(); - } - - componentDidUpdate(prevProps: EuiToolTipProps, prevState: State) { - if (prevState.visible === false && this.state.visible === true) { - requestAnimationFrame(this.testAnchor); - } - - // update scroll listener - this.repositionOnScroll.update(); - } - - testAnchor = () => { - // when the tooltip is visible, this checks if the anchor is still part of document - // this fixes when the react root is removed from the dom without unmounting - // https://github.com/elastic/eui/issues/1105 - if (document.body.contains(this.anchor) === false) { - // the anchor is no longer part of `document` - this.hideToolTip(); - } else { - if (this.state.visible) { - // if still visible, keep checking - requestAnimationFrame(this.testAnchor); - } - } - }; - - setAnchorRef = (ref: HTMLElement) => (this.anchor = ref); - - setPopoverRef = (ref: HTMLElement) => (this.popover = ref); - - showToolTip = () => { - if (!this.timeoutId) { - this.timeoutId = setTimeout(() => { - enqueueStateChange(() => { - this.setState({ visible: true }); - toolTipManager.registerTooltip(this.hideToolTip); - }); - }, delayToMsMap[this.props.delay]); - } - }; - - positionToolTip = () => { - const requestedPosition = this.props.position; - const offset = this.props.offset ?? DEFAULT_TOOLTIP_OFFSET; - - if (!this.anchor || !this.popover) { - return; - } - - const { position, left, top, arrow } = findPopoverPosition({ - anchor: this.anchor, - popover: this.popover, - position: requestedPosition, - offset, - arrowConfig: { - arrowWidth: 12, - arrowBuffer: 4, - }, - }); - - // If encroaching the right edge of the window: - // When `props.content` changes and is longer than `prevProps.content`, the tooltip width remains and - // the resizeObserver callback will fire twice (once for vertical resize caused by text line wrapping, - // once for a subsequent position correction) and cause a flash rerender and reposition. - // To prevent this, we can orient from the right so that text line wrapping does not occur, negating - // the second resizeObserver callback call. - const windowWidth = - document.documentElement.clientWidth || window.innerWidth; - const useRightValue = windowWidth / 2 < left; - - const toolTipStyles: ToolTipStyles = { - top, - left: useRightValue ? 'auto' : left, - right: useRightValue - ? windowWidth - left - this.popover.offsetWidth - : 'auto', - }; - - this.setState({ - visible: true, - calculatedPosition: position, - toolTipStyles, - arrowStyles: arrow, - }); - }; - - hideToolTip = () => { - this.clearAnimationTimeout(); - enqueueStateChange(() => { - if (this._isMounted) { - this.setState({ - visible: false, - toolTipStyles: DEFAULT_TOOLTIP_STYLES, - arrowStyles: undefined, - }); - toolTipManager.deregisterToolTip(this.hideToolTip); - } - }); - }; - - onFocus = () => { - this.setState({ - hasFocus: true, - }); - this.showToolTip(); - }; - - onBlur = () => { - this.setState({ - hasFocus: false, - }); - this.hideToolTip(); - }; - - onEscapeKey = (event: React.KeyboardEvent) => { - if (event.key === keys.ESCAPE) { - // when the tooltip is only visual, we don't want it to add an additional key stop - if (!this.props.disableScreenReaderOutput) { - if (this.state.visible) event.stopPropagation(); - } - this.setState({ hasFocus: false }); // Allows mousing over back into the tooltip to work correctly - this.hideToolTip(); - } - }; - - onMouseOut = (event: ReactMouseEvent) => { - // Prevent mousing over children from hiding the tooltip by testing for whether the mouse has - // left the anchor for a non-child. - if ( - this.anchor === event.relatedTarget || - (this.anchor != null && - !this.anchor.contains(event.relatedTarget as Node)) - ) { - if (!this.state.hasFocus) { - this.hideToolTip(); - } - } - - if (this.props.onMouseOut) { - this.props.onMouseOut(event); - } - }; - - render() { - const { +export const EuiToolTip = forwardRef( + ( + { children, className, anchorClassName, anchorProps, content, title, - delay, - display, + display = 'inlineBlock', repositionOnScroll, disableScreenReaderOutput = false, + position: positionProp = 'top', + offset, + id: idProp, + onMouseOut: onMouseOutProp, ...rest - } = this.props; + }, + ref + ) => { + const componentDefaultsContext = useContext(EuiComponentDefaultsContext); + + const [visible, setVisible] = useState(false); + const [hasFocus, setHasFocus] = useState(false); + const [calculatedPosition, setCalculatedPosition] = + useState(positionProp); + const [toolTipStyles, setToolTipStyles] = useState( + DEFAULT_TOOLTIP_STYLES + ); + const [arrowStyles, setArrowStyles] = useState< + Record | undefined + >(undefined); + + const [id] = useState(() => idProp || htmlIdGenerator()()); + + const anchorRef = useRef(null); + const popoverRef = useRef(null); + const isMounted = useRef(false); + const showPendingRef = useRef(false); + + const setAnchorRef = useCallback((el: HTMLElement) => { + anchorRef.current = el; + }, []); + + const setPopoverRef = useCallback((el: HTMLElement) => { + popoverRef.current = el; + }, []); + + const hideToolTip = useCallback(() => { + showPendingRef.current = false; + enqueueStateChange(() => { + if (isMounted.current) { + setVisible(false); + setToolTipStyles(DEFAULT_TOOLTIP_STYLES); + setArrowStyles(undefined); + toolTipManager.deregisterToolTip(hideToolTip); + } + }); + }, []); + + const positionToolTip = useCallback(() => { + if (!anchorRef.current || !popoverRef.current) { + return; + } - const { arrowStyles, id, toolTipStyles, visible, calculatedPosition } = - this.state; + const { position, left, top, arrow } = findPopoverPosition({ + anchor: anchorRef.current, + popover: popoverRef.current, + position: positionProp, + offset: offset ?? DEFAULT_TOOLTIP_OFFSET, + arrowConfig: { + arrowWidth: 12, + arrowBuffer: 4, + }, + }); + + // If encroaching the right edge of the window: + // When `props.content` changes and is longer than `prevProps.content`, the tooltip width remains and + // the resizeObserver callback will fire twice (once for vertical resize caused by text line wrapping, + // once for a subsequent position correction) and cause a flash rerender and reposition. + // To prevent this, we can orient from the right so that text line wrapping does not occur, negating + // the second resizeObserver callback call. + const windowWidth = + document.documentElement.clientWidth || window.innerWidth; + const useRightValue = windowWidth / 2 < left; + + const newToolTipStyles: ToolTipStyles = { + top, + left: useRightValue ? 'auto' : left, + right: useRightValue + ? windowWidth - left - popoverRef.current.offsetWidth + : 'auto', + }; + + setVisible(true); + setCalculatedPosition(position); + setToolTipStyles(newToolTipStyles); + setArrowStyles(arrow); + }, [positionProp, offset]); + + const showToolTip = useCallback(() => { + if (!showPendingRef.current) { + showPendingRef.current = true; + enqueueStateChange(() => { + showPendingRef.current = false; + if (isMounted.current) { + setVisible(true); + toolTipManager.registerTooltip(hideToolTip); + } + }); + } + }, [hideToolTip]); + + useImperativeHandle(ref, () => ({ showToolTip, hideToolTip, id }), [ + showToolTip, + hideToolTip, + id, + ]); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + showPendingRef.current = false; + toolTipManager.deregisterToolTip(hideToolTip); + }; + }, [hideToolTip]); + + // When the tooltip is visible, this checks if the anchor is still part of document. + // This fixes when the react root is removed from the DOM without unmounting + // See: https://github.com/elastic/eui/issues/1105 + useEffect(() => { + if (!visible) return; + + let rafId: number; + const testAnchor = () => { + if (document.body.contains(anchorRef.current) === false) { + // the anchor is no longer part of `document` + hideToolTip(); + } else { + rafId = requestAnimationFrame(testAnchor); + } + }; + rafId = requestAnimationFrame(testAnchor); + + return () => { + cancelAnimationFrame(rafId); + }; + }, [visible, hideToolTip]); + + // update scroll listener + useEffect(() => { + const shouldReposition = getRepositionOnScroll({ + repositionOnScroll, + repositionFn: positionToolTip, + componentDefaults: componentDefaultsContext.EuiToolTip, + }); + + if (shouldReposition) { + window.addEventListener('scroll', positionToolTip, true); + } + + return () => { + window.removeEventListener('scroll', positionToolTip, true); + }; + }, [ + repositionOnScroll, + positionToolTip, + componentDefaultsContext.EuiToolTip, + ]); + + const onFocus = useCallback(() => { + setHasFocus(true); + showToolTip(); + }, [showToolTip]); + + const onBlur = useCallback(() => { + setHasFocus(false); + hideToolTip(); + }, [hideToolTip]); + + const onEscapeKey = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === keys.ESCAPE) { + // when the tooltip is only visual, we don't want it to add an additional key stop + if (!disableScreenReaderOutput) { + if (visible) event.stopPropagation(); + } + setHasFocus(false); // Allows mousing over back into the tooltip to work correctly + hideToolTip(); + } + }, + [disableScreenReaderOutput, visible, hideToolTip] + ); + + const onMouseOut = useCallback( + (event: ReactMouseEvent) => { + // Prevent mousing over children from hiding the tooltip by testing for whether the mouse has + // left the anchor for a non-child. + if ( + anchorRef.current === event.relatedTarget || + (anchorRef.current != null && + !anchorRef.current.contains(event.relatedTarget as Node)) + ) { + if (!hasFocus) { + hideToolTip(); + } + } + + if (onMouseOutProp) { + onMouseOutProp(event); + } + }, + [hasFocus, hideToolTip, onMouseOutProp] + ); const classes = classNames('euiToolTip', className); const anchorClasses = classNames(anchorClassName, anchorProps?.className); @@ -362,12 +356,12 @@ export class EuiToolTip extends Component { <> { { className="euiToolTip__arrow" position={calculatedPosition} /> - + {(resizeRef) =>
    {content}
    }
    @@ -403,4 +397,6 @@ export class EuiToolTip extends Component { ); } -} +); + +EuiToolTip.displayName = 'EuiToolTip'; diff --git a/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx b/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx index 27a9dae9cefc..63a2797586aa 100644 --- a/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip_anchor.tsx @@ -9,7 +9,7 @@ import React, { cloneElement, HTMLAttributes, forwardRef } from 'react'; import classNames from 'classnames'; -import { useGeneratedHtmlId } from '../../services'; +import { useGeneratedHtmlId, useEuiMemoizedStyles } from '../../services'; import type { EuiToolTipProps } from './tool_tip'; import { euiToolTipAnchorStyles } from './tool_tip.styles'; @@ -42,7 +42,7 @@ export const EuiToolTipAnchor = forwardRef< }, ref ) => { - const anchorCss = euiToolTipAnchorStyles(); + const anchorCss = useEuiMemoizedStyles(euiToolTipAnchorStyles); const cssStyles = [anchorCss.euiToolTipAnchor, anchorCss[display]]; const classes = classNames('euiToolTipAnchor', className); diff --git a/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts b/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts index 2f32aa74ba28..502d4ff15358 100644 --- a/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts +++ b/packages/eui/src/components/tool_tip/tool_tip_manager.test.ts @@ -9,35 +9,44 @@ import { toolTipManager } from './tool_tip_manager'; describe('ToolTipManager', () => { - describe('registerToolTip', () => { - const hideToolTip = jest.fn(); + afterEach(() => { + // Reset the singleton between tests to prevent cross-test contamination + toolTipManager.toolTipsToHide.clear(); + }); + + describe('registerTooltip', () => { + it('does not call the newly registered callback', () => { + const hide = jest.fn(); - it('stores the passed hideToolTip callback', () => { - toolTipManager.registerTooltip(hideToolTip); + toolTipManager.registerTooltip(hide); - expect(toolTipManager.toolTipsToHide.has(hideToolTip)).toBeTruthy(); + expect(hide).not.toHaveBeenCalled(); }); - it('calls the previously stored hideToolTip callback and removes it from storage', () => { - toolTipManager.registerTooltip(() => {}); + it('calls and removes any previously registered callback when a new tooltip registers', () => { + const hide1 = jest.fn(); + const hide2 = jest.fn(); + + toolTipManager.registerTooltip(hide1); + toolTipManager.registerTooltip(hide2); - expect(hideToolTip).toHaveBeenCalledTimes(1); - expect(toolTipManager.toolTipsToHide.has(hideToolTip)).toBeFalsy(); + expect(hide1).toHaveBeenCalledTimes(1); + expect(hide2).not.toHaveBeenCalled(); }); }); describe('deregisterToolTip', () => { - // If the current tooltip is already hidden before the next tooltip is visible, - // there's no need to re-hide it, so we deregister the callback - const deregisteredHide = jest.fn(); + it('prevents a deregistered callback from being called when a new tooltip registers', () => { + // If the current tooltip is already hidden before the next tooltip is visible, + // there's no need to re-hide it, so we deregister the callback + const hide1 = jest.fn(); + const hide2 = jest.fn(); - it('removes the hide callback from storage', () => { - toolTipManager.registerTooltip(deregisteredHide); - toolTipManager.deregisterToolTip(deregisteredHide); - toolTipManager.registerTooltip(() => {}); + toolTipManager.registerTooltip(hide1); + toolTipManager.deregisterToolTip(hide1); + toolTipManager.registerTooltip(hide2); - expect(deregisteredHide).toHaveBeenCalledTimes(0); - expect(toolTipManager.toolTipsToHide.has(deregisteredHide)).toBeFalsy(); + expect(hide1).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/eui/src/components/tool_tip/tool_tip_popover.tsx b/packages/eui/src/components/tool_tip/tool_tip_popover.tsx index a561b30b7c14..8770362b5051 100644 --- a/packages/eui/src/components/tool_tip/tool_tip_popover.tsx +++ b/packages/eui/src/components/tool_tip/tool_tip_popover.tsx @@ -16,7 +16,7 @@ import React, { } from 'react'; import classNames from 'classnames'; import { CommonProps } from '../common'; -import { useEuiTheme } from '../../services'; +import { useEuiMemoizedStyles } from '../../services'; import { euiToolTipStyles } from './tool_tip.styles'; export type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left'; @@ -41,8 +41,7 @@ export const EuiToolTipPopover: FunctionComponent = ({ }) => { const popover = useRef(); - const euiTheme = useEuiTheme(); - const styles = euiToolTipStyles(euiTheme); + const styles = useEuiMemoizedStyles(euiToolTipStyles); const cssStyles = [ styles.euiToolTip, calculatedPosition && styles[calculatedPosition], diff --git a/packages/eui/src/services/hooks/index.ts b/packages/eui/src/services/hooks/index.ts index 178364248ae4..9e5d5695ea4c 100644 --- a/packages/eui/src/services/hooks/index.ts +++ b/packages/eui/src/services/hooks/index.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -export * from './useDependentState'; -export * from './useCombinedRefs'; -export * from './useForceRender'; -export * from './useLatest'; -export * from './useDeepEqual'; -export * from './useMouseMove'; -export * from './useIsPointerDown'; -export * from './useUpdateEffect'; +export { useDependentState } from './useDependentState'; +export { useCombinedRefs, setMultipleRefs } from './useCombinedRefs'; +export { useForceRender } from './useForceRender'; +export { useLatest } from './useLatest'; +export { useDeepEqual } from './useDeepEqual'; +export { isMouseEvent, useMouseMove } from './useMouseMove'; +export { useIsPointerDown } from './useIsPointerDown'; +export { useUpdateEffect } from './useUpdateEffect'; export { type EuiDisabledProps, useEuiDisabledElement, diff --git a/packages/eui/src/services/hooks/useIsPointerDown.test.tsx b/packages/eui/src/services/hooks/useIsPointerDown.test.tsx index c871047157e5..975b9226fe5f 100644 --- a/packages/eui/src/services/hooks/useIsPointerDown.test.tsx +++ b/packages/eui/src/services/hooks/useIsPointerDown.test.tsx @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import React, { useRef } from 'react'; +import React, { type MutableRefObject, useRef } from 'react'; import { act } from '@testing-library/react'; -import { render } from '../../test/rtl'; +import { render, renderHook, renderHookAct } from '../../test/rtl'; import { useIsPointerDown } from './useIsPointerDown'; @@ -26,39 +26,36 @@ global.PointerEvent = MockPointerEvent; describe('useIsPointerDown', () => { describe('without container', () => { it('returns true when pointer is down and false when pointer is up', () => { - let isPointerDown: boolean; + const { + result: { current: ref }, + } = renderHook(useIsPointerDown); - const TestComponent = () => { - isPointerDown = useIsPointerDown(); - return null; - }; - - render(); - expect(isPointerDown!).toBe(false); + expect(ref.current).toBe(false); - act(() => { + renderHookAct(() => { document.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(true); - act(() => { + expect(ref.current).toBe(true); + + renderHookAct(() => { document.dispatchEvent( new PointerEvent('pointerup', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(false); + expect(ref.current).toBe(false); }); }); describe('with container', () => { it('returns true when pointer is down inside the container', () => { - let isPointerDown: boolean; + let isPointerDownRef: MutableRefObject = { current: false }; const TestComponent = () => { const containerRef = useRef(null); - isPointerDown = useIsPointerDown(containerRef); + isPointerDownRef = useIsPointerDown(containerRef); return (
    @@ -68,7 +65,7 @@ describe('useIsPointerDown', () => { }; const { getByTestSubject } = render(); - expect(isPointerDown!).toBe(false); + expect(isPointerDownRef.current).toBe(false); act(() => { const container = getByTestSubject('container'); @@ -76,22 +73,22 @@ describe('useIsPointerDown', () => { new PointerEvent('pointerdown', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(true); + expect(isPointerDownRef.current).toBe(true); act(() => { document.dispatchEvent( new PointerEvent('pointerup', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(false); + expect(isPointerDownRef.current).toBe(false); }); it('returns false when pointer is down outside the container', () => { - let isPointerDown: boolean; + let isPointerDownRef: MutableRefObject = { current: false }; const TestComponent = () => { const containerRef = useRef(null); - isPointerDown = useIsPointerDown(containerRef); + isPointerDownRef = useIsPointerDown(containerRef); return (
    @@ -101,7 +98,7 @@ describe('useIsPointerDown', () => { }; const { getByTestSubject } = render(); - expect(isPointerDown!).toBe(false); + expect(isPointerDownRef.current).toBe(false); act(() => { const outside = getByTestSubject('outside'); @@ -109,61 +106,51 @@ describe('useIsPointerDown', () => { new PointerEvent('pointerdown', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(false); + expect(isPointerDownRef.current).toBe(false); }); }); describe('pointercancel and visibilitychange events', () => { it('resets to false on pointercancel', () => { - let isPointerDown: boolean; - - const TestComponent = () => { - isPointerDown = useIsPointerDown(); - return null; - }; + const { + result: { current: ref }, + } = renderHook(useIsPointerDown); - render(); - - act(() => { + renderHookAct(() => { document.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(true); + expect(ref.current).toBe(true); - act(() => { + renderHookAct(() => { document.dispatchEvent( new PointerEvent('pointercancel', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(false); + expect(ref.current).toBe(false); }); it('resets to false when document becomes hidden', () => { - let isPointerDown: boolean; + const { + result: { current: ref }, + } = renderHook(useIsPointerDown); - const TestComponent = () => { - isPointerDown = useIsPointerDown(); - return null; - }; - - render(); - - act(() => { + renderHookAct(() => { document.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }) ); }); - expect(isPointerDown!).toBe(true); + expect(ref.current).toBe(true); - act(() => { + renderHookAct(() => { Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true, }); document.dispatchEvent(new Event('visibilitychange')); }); - expect(isPointerDown!).toBe(false); + expect(ref.current).toBe(false); // reset Object.defineProperty(document, 'visibilityState', { diff --git a/packages/eui/src/services/hooks/useIsPointerDown.ts b/packages/eui/src/services/hooks/useIsPointerDown.ts index fb0586ea6f64..7792e6fc35eb 100644 --- a/packages/eui/src/services/hooks/useIsPointerDown.ts +++ b/packages/eui/src/services/hooks/useIsPointerDown.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useState, useEffect, MutableRefObject } from 'react'; +import { useRef, useEffect, type MutableRefObject } from 'react'; /** * A hook that tracks whether the pointer is currently down/pressed. @@ -15,7 +15,7 @@ import { useState, useEffect, MutableRefObject } from 'react'; export function useIsPointerDown( container?: MutableRefObject ) { - const [isPointerDown, setIsPointerDown] = useState(false); + const isPointerDownRef = useRef(false); useEffect(() => { const handlePointerDown = (event: PointerEvent) => { @@ -25,16 +25,16 @@ export function useIsPointerDown( ) { return; } - setIsPointerDown(true); + isPointerDownRef.current = true; }; const handlePointerUpOrCancel = () => { - setIsPointerDown(false); + isPointerDownRef.current = false; }; const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { - setIsPointerDown(false); + isPointerDownRef.current = false; } }; @@ -57,5 +57,5 @@ export function useIsPointerDown( }; }, [container]); - return isPointerDown; + return isPointerDownRef; } diff --git a/packages/eui/src/services/index.ts b/packages/eui/src/services/index.ts index 5eaeefd288fc..23a836eeb99d 100644 --- a/packages/eui/src/services/index.ts +++ b/packages/eui/src/services/index.ts @@ -85,7 +85,19 @@ export { formatNumber, formatText, } from './format'; -export * from './hooks'; +export { + useDependentState, + useCombinedRefs, + setMultipleRefs, + useForceRender, + useLatest, + useDeepEqual, + isMouseEvent, + useMouseMove, + useUpdateEffect, + useEuiDisabledElement, + type EuiDisabledProps, +} from './hooks'; export { isEvenlyDivisibleBy, isWithinRange } from './number'; export { Pager } from './paging'; export { calculatePopoverPosition, findPopoverPosition } from './popover'; diff --git a/packages/website/docs/components/containers/flyout/_session_management.mdx b/packages/website/docs/components/containers/flyout/_session_management.mdx index dff822fdab6e..33baa441948e 100644 --- a/packages/website/docs/components/containers/flyout/_session_management.mdx +++ b/packages/website/docs/components/containers/flyout/_session_management.mdx @@ -201,6 +201,191 @@ the history popover β€” it is not shown in the flyout's menu bar. ``` +##### Scoping history (`historyKey`) + +By default, every `session="start"` flyout gets its own isolated history group - +the Back button and history popover only ever show entries from flyouts that are +part of the same group. + +In some applications, multiple areas of the product can open `session="start"` +flyouts independently (for example, an Alerts panel and a Logs panel that are +both active at the same time). Without any extra configuration these areas each +have their own history. + +In the example below, the two **Alerts** buttons share a `historyKey` so their +flyouts form a single history group β€” use the Back button or history popover to +navigate between them. The **Logs** button uses a separate key, so its flyout +has its own isolated history. + +```tsx interactive +import React, { useState } from "react"; +import { + EuiButton, + EuiCode, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, +} from "@elastic/eui"; + +const alertsKey = Symbol("alerts"); +const logsKey = Symbol("logs"); + +export default () => { + const [showAlertOverview, setShowAlertOverview] = useState(false); + const [showAlertDetail, setShowAlertDetail] = useState(false); + const [showLogs, setShowLogs] = useState(false); + + return ( + <> + + + setShowAlertOverview(true)} + disabled={showAlertOverview} + > + Alert overview + + + + setShowAlertDetail(true)} + disabled={showAlertDetail} + > + Alert detail + + + + setShowLogs(true)} + disabled={showLogs} + > + Logs + + + + + {showAlertOverview && ( + setShowAlertOverview(false)} + > + + +

    Alert overview

    +
    +
    + + +

    + This flyout shares a historyKey with{" "} + Alert detail. Open both to see them appear in + the same history group. +

    +
    +
    +
    + )} + + {showAlertDetail && ( + setShowAlertDetail(false)} + > + + +

    Alert detail

    +
    +
    + + +

    + This flyout shares a historyKey with{" "} + Alert overview. They form a single history + group, connected by the Back button. +

    +
    +
    +
    + )} + + {showLogs && ( + setShowLogs(false)} + > + + +

    Logs

    +
    +
    + + +

    + This flyout uses a different historyKey than + the Alerts flyouts. Its history is completely independent. +

    +
    +
    +
    + )} + + ); +}; +``` + +However, if you want **two or more independently-rendered flyouts to share a single +history** β€” so that navigating Back can move between them β€” you can pass the same +`Symbol` reference as the `historyKey` prop to each flyout: + +```tsx +// Create the key once, outside the component (or in a shared module). +const alertsHistoryKey = Symbol('alerts'); + +// Both flyouts below will share the same Back button and history entries. + + {/* ... */} + + + + {/* ... */} + +``` + +Because JavaScript `Symbol` identity is used for the comparison, you must pass the +**same `Symbol` reference**. + +:::info Note +`historyKey` is only meaningful on `session="start"` (main) flyouts. Child flyouts +don't carry their own key β€” they attach to the most recent session, which already +has the key set by its main flyout. You should not pass `historyKey` on child flyouts. +::: + +Closing a managed flyout (via the Γ— button or `ACTION_CLOSE_ALL`) will close all +flyouts in the **same history group** and leave flyouts belonging to other groups +untouched. + ##### Controlling session participation To prevent a flyout from being a part of the session management system: diff --git a/packages/website/docs/components/display/icons/icon_types.ts b/packages/website/docs/components/display/icons/icon_types.ts index c7afb1dba35b..c703c6e2a2a3 100644 --- a/packages/website/docs/components/display/icons/icon_types.ts +++ b/packages/website/docs/components/display/icons/icon_types.ts @@ -313,6 +313,8 @@ export const iconTypes: Array = [ 'thumbUp', 'timeline', 'timelineWithArrow', // To be deprecated + 'transitionBottomIn', + 'transitionBottomOut', 'transitionLeftIn', 'transitionLeftOut', 'transitionTopIn', diff --git a/packages/website/docs/components/display/tooltip.mdx b/packages/website/docs/components/display/tooltip.mdx index 6c9ac67e16f6..318e5226d46f 100644 --- a/packages/website/docs/components/display/tooltip.mdx +++ b/packages/website/docs/components/display/tooltip.mdx @@ -50,15 +50,6 @@ export default () => ( .

    -

    - This tooltip has a long delay because it might be in - a repeatable component{' '} - - wink - - . -

    -

    This tooltip appears on the bottom of this icon:{' '} diff --git a/packages/website/static/versions.json b/packages/website/static/versions.json index e168c0361c3f..0db8e37bd0ab 100644 --- a/packages/website/static/versions.json +++ b/packages/website/static/versions.json @@ -1,5 +1,6 @@ { "euiVersions": [ + "114.0.0", "113.3.0", "113.2.1", "113.2.0", diff --git a/wiki/contributing-to-eui/README.md b/wiki/contributing-to-eui/README.md index d6f07c52d3ef..ce87ed427c6d 100644 --- a/wiki/contributing-to-eui/README.md +++ b/wiki/contributing-to-eui/README.md @@ -2,44 +2,45 @@ πŸ™Œ Thanks for your interest in contributing to EUI! πŸ™Œ -If there isn't an associated feature request or bug report in EUI's backlog yet, [please create an issue](https://github.com/elastic/eui/issues/new) so that you get a chance to discuss the changes you have in mind with the EUI team. This helps the team scope out your work and provide guidance & recommendations. +We welcome and encourage contributions. -## Process +However, because EUI has a large footprint in Elastic products, we must maintain a high standard of quality and due diligence for contributions. The guidance below outlines our expectations. **While we'd hate to turn away any contribution, PRs that deviate from this guidance will most likely be rejected.** +**** +## Who can contribute -### Who can contribute? +- **Elastic employees** β€” EUI is built primarily for Elastic products; maintainers prioritize internal roadmap work first. If your need isn't currently a priority, feel free to contribute the solution yourself. +- **Community** β€” Outside contributions are welcome and merged on a **best-effort** basis. -EUI is built primarily by and for employees of Elastic. We align our features and roadmap with the needs of our products internally. +## What to contribute -While EUI's primary customer is Kibana and other Elastic products, open source is a part of our DNA at Elastic, and commmunity contributions from outside of Elastic are welcome. These contributions are typically reviewed and merged on a best-effort basis and must generally align with the overall objectives of this project. +- **Bug fixes** β€” Especially clear, scoped fixes for reproducible issues. +- **[Help wanted](https://github.com/elastic/eui/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)** β€” Issues with this label are meant for community pickup: we’ve checked that they fit our roadmap and aren’t blocked on private internal planning. -In general, once on Github, any issue can be worked on by the community. If you find an issue that is not assigned, assume that you are welcome to work on it and can submit a pull request. We recommend that you leave us a comment indicating your intent before starting work to avoid potential conflict. We do not, as a policy, assign issues to community members and we usually reserve larger projects or ones that are core to our roadmap or design to be done internally. +## What not to contribute -Our best PRs tend to come from existing users whom have a challenge they are attempting to overcome and wish to help us solve it. If you are new to open source and looking for a good project to start making contributions, this project may not be a good fit. We have an extensive backlog in which you'll likely encounter outdated issues and issues lacking the appropriate context to get started. +Unless agreed upon previously: -### How to ensure the timely review of pull requests +- **Large or core roadmap work** β€” Often handled internally so architecture, accessibility, and downstream coordination stay aligned. +- **Style or design changes** β€” These require alignment with design teams at Elastic. +- **High-impact changes** - Even small code changes can have a large impact on Kibana, requiring significant testing and review. -To help the maintainers of EUI better respond to your pull requests please try to adhere to the following: +## How to contribute -1. Follow the guidelines outlined in this wiki folder, from [development](./developing) to [documentation](./documenting) to [testing](./testing). -2. Prefer [atomic commits and small pull requests](https://learning-notes.mistermicheels.com/processes-techniques/small-commits-pull-requests/). Try to limit each commit and each PR to one concern/one issue only. PRs will be significantly easier to QA and review this way. -3. Include screenshots and a summary of your changes in the PR description -4. Fill out the checklist, using ~strikethroughs~ to mark any items that are not applicable -5. Make sure your changes are documented on the demo site and include code comments for unintuitive changes -6. Treat each other professionally and assume best intent in each others work and suggestions +- **Comment before you start** β€” Note your intent on the issue to avoid duplicate work. We don’t assign issues to community members by policy. +- **No matching issue yet?** **[Open one](https://github.com/elastic/eui/issues/new)** first so maintainers can scope the change before you invest in a PR. -Generally you can expect feedback and a review of your pull request from our team within a week. Contributors should limit themselves to three or less active PRs at any one time, which helps us focus review time towards PRs that are close to a merge event. Sometimes it is unclear who has the next step in getting a pull request over the line and the review can lag as a result. If this is the case, feel free to leave a new comment and ask for guidance. +## Pull requests -### Feel free to submit pull requests in draft stages +- **Use this wiki** β€” [Developing](./developing), [Documenting](./documenting), [Testing](./testing). +- **Keep PRs small** β€” Prefer **[atomic commits and focused PRs](https://learning-notes.mistermicheels.com/processes-techniques/small-commits-pull-requests/)** (one concern per commit and per PR when you can). +- **Review timing** β€” We aim to respond within **about a week**. +- **Concurrency** β€” Please limit open PRs so review stays focused. +- **Due diligence** β€” Follow the PR template (summary, impact, screenshots, checklists, QA notes). +- **Draft PRs** β€” We don’t review drafts by default. When you want feedback or help, **comment and ping `@eui-team`** (same idea as the note at the top of the PR template). -EUI has strict quality and testing standards due to its large downstream footprint and accessibility requirements. Don't feel intimidated and think you need to submit perfect PRs because of this. We welcome draft PRs to show conceptual ideas or enhancements you would like to see. The EUI team normally engages on these PRs in one of two ways, which is largely up to you. +## Stale PRs -1. We can provide review and guidance for how to get the PR up to the library's standards. (slower, but you might enjoy this) -2. We can commit directly to your PR to get it over the finish line. (faster) +Inactive PRs (~**3 months**) may be closed by GitHub’s **[stale workflow](https://github.com/actions/stale)**. -If you have a preference, let us know when you make your PR, but never feel guilty about just handing it off. We're here to help. - -### Stale PRs - -Stale PRs will be automatically closed by Github's [actions/stale workflow](https://github.com/actions/stale) if they are inactive for ~3 months. If the ball is in EUI's court in terms of review, please feel free to ping us to get the feedback round back in motion. - -If the ball is in your court in terms of feedback given and changes requested, and the PR ends up auto-closing due to inactivity, the EUI team may take over your PR either by pushing to it directly or closing your PR and opening another that branches off your existing work. +- If **we** owe review, ping us to get things moving again. +- If **you** owed changes and the PR auto-closed, we may push to your branch or close it and continue in a new PR based on your work. diff --git a/wiki/contributing-to-eui/developing/creating-icons.md b/wiki/contributing-to-eui/developing/creating-icons.md index 7af46d29b1a8..8d637e84baa4 100644 --- a/wiki/contributing-to-eui/developing/creating-icons.md +++ b/wiki/contributing-to-eui/developing/creating-icons.md @@ -8,7 +8,7 @@ If you read through these guidelines or begin designing your icon and realize yo _**Note on 3rd-party / custom SVGs**_ - The `EuiIcon` component accepts external references to custom SVG files, so you can maintain the icon in your consuming application. -- This practice should also be used for any **3rd-party logos**. For a number of reasons, the EUI team as moved away from maintaining a set of 3rd party logos. Please use the custom SVG option going forward. +- This practice should also be used for any **3rd-party logos**. For a number of reasons, the EUI team has moved away from maintaining a set of 3rd party logos. Please use the custom SVG option going forward. ## Content diff --git a/wiki/contributing-to-eui/testing/testing-in-kibana.md b/wiki/contributing-to-eui/testing/testing-in-kibana.md index 7ca99acf430c..9e7956dd2fcc 100644 --- a/wiki/contributing-to-eui/testing/testing-in-kibana.md +++ b/wiki/contributing-to-eui/testing/testing-in-kibana.md @@ -1,6 +1,35 @@ # Testing EUI features in Kibana ahead of time -For changes that may have major implications on existing Kibana usages of EUI, a built version of EUI should be tested against Kibana (ideally before being merged/released into EUI) to ensure that the [upgrade process](../../eui-team-processes/upgrading-kibana.md) is as painless as possible. +Most PRs should be tested in Kibana before merging into EUI main. Test a built version of EUI against Kibana and considering staging the integration [staging the integration](#staging-integrations) to ensure the upgrade process is as painless as possible. + +## Staging Integrations + +Use this **Staging Workflow** to assist the EUI team integrating your PR into Kibana during an upgrade. Useful when: +- PR involves breaking changes +- You want to apply a new feature in Kibana to ensure adoption +- Existing styles may need to be tweaked +- There are test failures, especially integration tests + +1. **Stage in Kibana:** Create a **Draft PR** in the Kibana repo. Use this to handle test failures, style tweaks, or API migrations. +2. **Reference in EUI PR:** Link the Kibana Draft in your EUI PR description. +3. **Final Upgrade:** The upgrader will cherry-pick your staged commits into the final Kibana version bump PR. + +### Example: Staging Workflow + +| Step | Link | Action | +| :--- | :--- | :--- | +| **1. Staging** | [Kibana Draft \#248805](https://github.com/elastic/kibana/pull/248805) | Create commits for API updates, style adjustments, and test fixes. | +| **2. Source** | [EUI PR \#9308](https://github.com/elastic/eui/pull/9308) | Note: *"All commits in the linked PR should be included in the upgrade."* | +| **3. Final** | [Kibana Upgrade \#253286](https://github.com/elastic/kibana/pull/253286) | Upgrader cherry-picks staged commits into the version bump. | + +### Recommended Commit Structure + +Keep staged commits atomic to simplify cherry-picking for the upgrader: + + * `refactor: update [some Kibana code] to use new EUI API` + * `style: adjust styles for [some EUI change]` + * `test: update snapshots for [some EUI change]` + * `test: fix broken integration tests for [some EUI change]` ## Testing local EUI in local Kibana diff --git a/yarn.lock b/yarn.lock index 27f7440f0e9e..c352cd18e23b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7062,7 +7062,7 @@ __metadata: languageName: node linkType: hard -"@elastic/eslint-plugin-eui@workspace:packages/eslint-plugin": +"@elastic/eslint-plugin-eui@workspace:*, @elastic/eslint-plugin-eui@workspace:packages/eslint-plugin": version: 0.0.0-use.local resolution: "@elastic/eslint-plugin-eui@workspace:packages/eslint-plugin" dependencies: @@ -7347,6 +7347,7 @@ __metadata: "@cypress/webpack-dev-server": "npm:^1.7.0" "@elastic/charts": "npm:^64.1.0" "@elastic/datemath": "npm:^5.0.3" + "@elastic/eslint-plugin-eui": "workspace:*" "@elastic/eui-theme-borealis": "workspace:*" "@elastic/eui-theme-common": "workspace:*" "@elastic/prismjs-esql": "npm:^1.1.2" @@ -7530,7 +7531,7 @@ __metadata: yo: "npm:^4.3.1" peerDependencies: "@elastic/datemath": ^5.0.2 - "@elastic/eui-theme-borealis": 6.2.0 + "@elastic/eui-theme-borealis": 7.0.0 "@emotion/css": 11.x "@emotion/react": 11.x "@types/react": ^17.0 || ^18.0