-
Notifications
You must be signed in to change notification settings - Fork 860
feat(eui): add a focus trap pubsub service #9103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(eui): add a focus trap pubsub service #9103
Conversation
f99a6ea to
9ffdff9
Compare
9ffdff9 to
db6685e
Compare
4d6940a to
97883f6
Compare
695e5b1 to
6d3c47c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WILL BE REVERTED
sorry for screaming
acstll
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I went through the QA steps provided and it's working as expected. The changes look good and the implementation is neat and clean (agree it's probably the best compared to the other possible approaches). Left a couple of comments.
We agreed with @weronikaolejniczak that it'll make sense to test this with the latest Flyout System changes as well, and that @tkajtoch should also take a look as well 🙂
| /** | ||
| * Finds the shards to include in the focus trap by querying by `focusTrapSelectors`. | ||
| * | ||
| * @param shouldAutoFocus Whether to auto-focus the flyout wrapper when the focus trap is activated. | ||
| * This is necessary because when a flyout is toggled from within a shard, the focus trap's `autoFocus` | ||
| * feature doesn't work. This logic manually focuses the flyout as a workaround. | ||
| */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JSDoc FTW, thanks for adding this 💚
acstll
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks again for taking the time to address my comments @weronikaolejniczak
I'm approving knowing that we agree
- @tkajtoch will also do a review
- it will get tested in Kibana (with a test PR like elastic/kibana#239500 and CI green) after the current work for integrating the flyout system is stable
077d68f to
228bdda
Compare
tkajtoch
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like what I see in this PR very much. The solution seems thought out and doesn't open us to any new risks.
My comments are just comments and I don't expect you to change anything. LGTM 🚢
| @@ -0,0 +1,73 @@ | |||
| /* | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Not a change request] Semantically, this implements the observer pattern and not the pub-sub pattern, but the differences are minimal. It doesn't change the fact that this solution works here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would say this is a hybrid approach, technically it's closer to Observer but conceptually it's closer to Pub/Sub. Why?
publish()does not know who the listeners are, it just broadcasts,focusTrapPubSubacts as a mediator (the "message broker"), components callpublishandsubscribeon it,- Flyout and Popover communicate without references to one another,
- the publisher doesn’t maintain observers tied to its own instance, it uses a shared event channel.
In the Observer pattern, the "subject" holds the list of observers. Here, no object owns the list, the listeners Set is a global event bus. This means multiple independent parts of the app can publish and subscribe. For now it's only Flyout being the subscriber and Popover the publisher but we could loop in other components as needed. Isn't that a Pub/Sub?
I'm genuinely curious, please let me know what was your thought process 😄
EDIT: One more thing that came to mind, I always thought of Observer as a tightly-coupling pattern while Pub/Sub as a loosely-coupling pattern, which is why Pub/Sub is great to use in a micro-frontend architecture for cross-module behaviors 👍🏻
EDIT 2: I guess what differs it from a true Pub/Sub is the "topics" (here we don't need them), and external message broker. Also the focusTrapPubSub is an in-memory singleton that synchronously loops through listeners, like an Observer subject. This is so nuanced, I don't think it's wrong to call it either Pub/Sub or Observer but the intention for this skews more for Pub/Sub 😅
| * This is necessary because when a flyout is toggled from within a shard, the focus trap's `autoFocus` | ||
| * feature doesn't work. This logic manually focuses the flyout as a workaround. | ||
| */ | ||
| const findShards = useCallback( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a thought: Even since we can't do anything with react-focus-on's shards property that accepts type Array<React.RefObject<any> | HTMLElement>, it would still be possible to make it dynamic based on the usage. The library only accesses the data on two events - onMouseDown and the internal onNodeActivation. That means shards is only read then, which makes it a potential good candidate for a Proxy array that would work similarly to HTMLCollection but using querySelectorAll. Whenever the array would be read, it would read a dynamic value based on the output of document.querySelectorAll at the time of read.
I'm not saying this would be a better solution. I'm just mentioning it as a way to bend the rules :D
The best overall solution would likely be to open a feature request and allow shards to be a function or something more dynamic in general for these use cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I love the idea of a Proxy array, it's very clever! 🧠 And removes the need for a subscription system.
I quickly whipped it up in a7faab2 and tested with the story:
Kapture.2025-11-07.at.12.03.24.mp4
We can always drop the commit in case we feel this is too risky (e.g. potential performance overhead due to frequent queries or the reliance of how react-focus-on handles shards array). I feel this would have to be re-tested thoroughly because it does affect other parts of the flyout code.
Lmk what you think, personally I'd revert to the pub/sub for potentially better performance and no compatibility risk but Proxy is a more elegant solution with less component coupling and less code to maintain.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huge props for actually testing this and comparing both solutions! My primary intention was to just mention it and not push you in either direction. Your solution works perfectly fine and thanks to its simplicity I feel confident we can merge it and be able to enhance it if we ever need to in the future. The proxy solution might be useful someday, somewhere else 👍🏻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For prosperity, the diff would be sth like:
const focusTrapSelectors = useMemo(() => {
let selectors: string[] = [];
if (includeSelectorInFocusTrap) {
selectors = Array.isArray(includeSelectorInFocusTrap)
? includeSelectorInFocusTrap
: [includeSelectorInFocusTrap];
}
if (includeFixedHeadersInFocusTrap) {
selectors.push('.euiHeader[data-fixed-header]');
}
return selectors;
}, [includeSelectorInFocusTrap, includeFixedHeadersInFocusTrap]);
const dynamicShards = useMemo(() => {
const userShards = _focusTrapProps?.shards || [];
if (focusTrapSelectors.length === 0) {
return userShards;
}
const getDynamicNodes = () =>
focusTrapSelectors.flatMap((selector) =>
Array.from(document.querySelectorAll<HTMLElement>(selector))
);
return new Proxy(userShards, {
get(target, prop) {
const allNodes = [
...target, // consumer-provided shards
...getDynamicNodes(),
];
const value = Reflect.get(allNodes, prop);
if (typeof value === 'function') {
return value.bind(allNodes);
}
return value;
},
});
}, [_focusTrapProps?.shards, focusTrapSelectors]);
const focusTrapProps: EuiFlyoutProps['focusTrapProps'] = useMemo(
() => ({
..._focusTrapProps,
shards: dynamicShards,
}),
[_focusTrapProps, dynamicShards]
);There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it was amazing watching this work in a7faab2 💪
I must say it's the first time I see Proxy being practically useful (besides the typical reactivity implementations in UI libraries) 💚
a7faab2 to
3e08a15
Compare
3e08a15 to
eed4fe4
Compare
💚 Build SucceededHistory
|
💚 Build Succeeded
History
|
- `@elastic/eui`: `v109.0.0` ⏩ `v109.1.0` - `@elastic/eslint-plugin-eui`: `v2.5.0` ⏩ `v2.6.0` --- ## Changes - Updated i18n EUI mapping 6cc95b0 - Updated test in Unified Search 668948f ## Package updates ### `@elastic/eui` [`v109.1.0`](https://github.com/elastic/eui/releases/v109.1.0) - Added `--euiBottomBarOffset` CSS variable to `EuiBottomBar` for positioning other fixed elements relative to the bottom bar's height ([#9211](elastic/eui#9211)) - Updated `boxesVertical` icon and restored `checkInCircleFilled`, `errorFilled`, and `warningFilled` icons. ([#9194](elastic/eui#9194)) - Updated `EuiSuperDatePicker` with new time zone information, opt-in via `timeZoneDisplayProps`. ([#9191](elastic/eui#9191)) - Updated the position of `EuiModal` by removing bottom padding in `EuiOverlayMask` ([#9190](elastic/eui#9190)) - Added `EuiPopover` and `EuiToolTip`'s `repositionOnScroll` to `componentDefaults` ([#9152](elastic/eui#9152)) - Updated `EuiSuperDatePicker` with new time window buttons for time shifting and zoom out, opt-in via `showTimeWindowButtons` boolean prop. ([#9151](elastic/eui#9151)) - Added beta prop `hasAriaDisabled` to all base button components: `EuiButton`, `EuiButtonEmpty`, `EuiButtonIcon`, `EuibuttonGroup`, `EuiFilterButton` ([#9201](elastic/eui#9201)) - Added `euiDisabledSelector` variable that combines CSS selectors `:disabled` and `[aria-disabled="true"]` ([#9201](elastic/eui#9201)) - Added custom test matchers that check for both `disabled` and `aria-disabled` attributes: ([#9201](elastic/eui#9201)) - React testing Library: `.toBeEuiDisabled()` - Enzyme: `.toHaveEuiDisabledProp()` - Cypress: `should('be.euiDisabled)` **Bug fixes** - Fixed unexpected duplicate columns in `EuiDataGrid` crashing the column sorting by removing duplicate columns entirely ([#9209](elastic/eui#9209)) - Fixed a visual bug in `EuiTable` where long table row content would be cut off on mobile screens ([#9206](elastic/eui#9206)) - Fixed virtualized `EuiCodeBlock` rendering blank lines when content updates if scrolled. ([#9196](elastic/eui#9196)) - Fixed `EuiButtonGroup` button sizing to ensure square buttons when used with `isIconOnly=true` ([#9170](elastic/eui#9170)) **Accessibility** - Fixed an issue where portalled components like `EuiPopover` were not included in `EuiFlyout`'s focus trap through `includeSelectorInFocusTrap`, making them inaccessible to keyboard users ([#9103](elastic/eui#9103)) ### `@elastic/eslint-plugin-eui` [`v2.6.0`](https://github.com/elastic/eui/blob/main/packages/eslint-plugin/changelogs/CHANGELOG_2025.md#v260) - Added new `require-table-caption` rule. ([#9168](elastic/eui#9168)) --------- Co-authored-by: Elastic Machine <[email protected]>
- `@elastic/eui`: `v109.0.0` ⏩ `v109.1.0` - `@elastic/eslint-plugin-eui`: `v2.5.0` ⏩ `v2.6.0` --- ## Changes - Updated i18n EUI mapping 6cc95b0 - Updated test in Unified Search 668948f ## Package updates ### `@elastic/eui` [`v109.1.0`](https://github.com/elastic/eui/releases/v109.1.0) - Added `--euiBottomBarOffset` CSS variable to `EuiBottomBar` for positioning other fixed elements relative to the bottom bar's height ([elastic#9211](elastic/eui#9211)) - Updated `boxesVertical` icon and restored `checkInCircleFilled`, `errorFilled`, and `warningFilled` icons. ([elastic#9194](elastic/eui#9194)) - Updated `EuiSuperDatePicker` with new time zone information, opt-in via `timeZoneDisplayProps`. ([elastic#9191](elastic/eui#9191)) - Updated the position of `EuiModal` by removing bottom padding in `EuiOverlayMask` ([elastic#9190](elastic/eui#9190)) - Added `EuiPopover` and `EuiToolTip`'s `repositionOnScroll` to `componentDefaults` ([elastic#9152](elastic/eui#9152)) - Updated `EuiSuperDatePicker` with new time window buttons for time shifting and zoom out, opt-in via `showTimeWindowButtons` boolean prop. ([elastic#9151](elastic/eui#9151)) - Added beta prop `hasAriaDisabled` to all base button components: `EuiButton`, `EuiButtonEmpty`, `EuiButtonIcon`, `EuibuttonGroup`, `EuiFilterButton` ([elastic#9201](elastic/eui#9201)) - Added `euiDisabledSelector` variable that combines CSS selectors `:disabled` and `[aria-disabled="true"]` ([elastic#9201](elastic/eui#9201)) - Added custom test matchers that check for both `disabled` and `aria-disabled` attributes: ([elastic#9201](elastic/eui#9201)) - React testing Library: `.toBeEuiDisabled()` - Enzyme: `.toHaveEuiDisabledProp()` - Cypress: `should('be.euiDisabled)` **Bug fixes** - Fixed unexpected duplicate columns in `EuiDataGrid` crashing the column sorting by removing duplicate columns entirely ([elastic#9209](elastic/eui#9209)) - Fixed a visual bug in `EuiTable` where long table row content would be cut off on mobile screens ([elastic#9206](elastic/eui#9206)) - Fixed virtualized `EuiCodeBlock` rendering blank lines when content updates if scrolled. ([elastic#9196](elastic/eui#9196)) - Fixed `EuiButtonGroup` button sizing to ensure square buttons when used with `isIconOnly=true` ([elastic#9170](elastic/eui#9170)) **Accessibility** - Fixed an issue where portalled components like `EuiPopover` were not included in `EuiFlyout`'s focus trap through `includeSelectorInFocusTrap`, making them inaccessible to keyboard users ([elastic#9103](elastic/eui#9103)) ### `@elastic/eslint-plugin-eui` [`v2.6.0`](https://github.com/elastic/eui/blob/main/packages/eslint-plugin/changelogs/CHANGELOG_2025.md#v260) - Added new `require-table-caption` rule. ([elastic#9168](elastic/eui#9168)) --------- Co-authored-by: Elastic Machine <[email protected]>
…ems not interactive (#243685) ## Summary There is a meaningful fix already on: #243612 It's going into 9.3 and cannot be backported because it relies on latest EUI updates ([v109.1.0](https://github.com/elastic/eui/releases/tag/v109.1.0)), specifically: > Fixed an issue where portalled components like EuiPopover were not included in EuiFlyout's focus trap through includeSelectorInFocusTrap, making them inaccessible to keyboard users (elastic/eui#9103) ## QA ### Flyouts https://github.com/user-attachments/assets/3957c82e-c5ed-4113-bd3d-5a8a3ffa5bd6 ### Security PageOverlay https://github.com/user-attachments/assets/9a491ba0-51b3-44d6-8d39-63aea538f2e6 ### Keyboard navigation https://github.com/user-attachments/assets/f1d8677c-5825-4ace-aa13-3b17b6b8b4e5
- `@elastic/eui`: `v109.0.0` ⏩ `v109.1.0` - `@elastic/eslint-plugin-eui`: `v2.5.0` ⏩ `v2.6.0` --- ## Changes - Updated i18n EUI mapping 6cc95b0 - Updated test in Unified Search 668948f ## Package updates ### `@elastic/eui` [`v109.1.0`](https://github.com/elastic/eui/releases/v109.1.0) - Added `--euiBottomBarOffset` CSS variable to `EuiBottomBar` for positioning other fixed elements relative to the bottom bar's height ([elastic#9211](elastic/eui#9211)) - Updated `boxesVertical` icon and restored `checkInCircleFilled`, `errorFilled`, and `warningFilled` icons. ([elastic#9194](elastic/eui#9194)) - Updated `EuiSuperDatePicker` with new time zone information, opt-in via `timeZoneDisplayProps`. ([elastic#9191](elastic/eui#9191)) - Updated the position of `EuiModal` by removing bottom padding in `EuiOverlayMask` ([elastic#9190](elastic/eui#9190)) - Added `EuiPopover` and `EuiToolTip`'s `repositionOnScroll` to `componentDefaults` ([elastic#9152](elastic/eui#9152)) - Updated `EuiSuperDatePicker` with new time window buttons for time shifting and zoom out, opt-in via `showTimeWindowButtons` boolean prop. ([elastic#9151](elastic/eui#9151)) - Added beta prop `hasAriaDisabled` to all base button components: `EuiButton`, `EuiButtonEmpty`, `EuiButtonIcon`, `EuibuttonGroup`, `EuiFilterButton` ([elastic#9201](elastic/eui#9201)) - Added `euiDisabledSelector` variable that combines CSS selectors `:disabled` and `[aria-disabled="true"]` ([elastic#9201](elastic/eui#9201)) - Added custom test matchers that check for both `disabled` and `aria-disabled` attributes: ([elastic#9201](elastic/eui#9201)) - React testing Library: `.toBeEuiDisabled()` - Enzyme: `.toHaveEuiDisabledProp()` - Cypress: `should('be.euiDisabled)` **Bug fixes** - Fixed unexpected duplicate columns in `EuiDataGrid` crashing the column sorting by removing duplicate columns entirely ([elastic#9209](elastic/eui#9209)) - Fixed a visual bug in `EuiTable` where long table row content would be cut off on mobile screens ([elastic#9206](elastic/eui#9206)) - Fixed virtualized `EuiCodeBlock` rendering blank lines when content updates if scrolled. ([elastic#9196](elastic/eui#9196)) - Fixed `EuiButtonGroup` button sizing to ensure square buttons when used with `isIconOnly=true` ([elastic#9170](elastic/eui#9170)) **Accessibility** - Fixed an issue where portalled components like `EuiPopover` were not included in `EuiFlyout`'s focus trap through `includeSelectorInFocusTrap`, making them inaccessible to keyboard users ([elastic#9103](elastic/eui#9103)) ### `@elastic/eslint-plugin-eui` [`v2.6.0`](https://github.com/elastic/eui/blob/main/packages/eslint-plugin/changelogs/CHANGELOG_2025.md#v260) - Added new `require-table-caption` rule. ([elastic#9168](elastic/eui#9168)) --------- Co-authored-by: Elastic Machine <[email protected]>
Summary
Changes:
EuiPopoveron opening, closing and unmounting the component,EuiFlyout, re-queries and updates thefocusTrapShards.Why are we making this change?
Resolves #9099
tl;dr;
To fix keyboard navigation for elements that show up later than the flyout renders and so are not queried statically with
includeSelectorInFocusTrap.More context
The
includeSelectorInFocusTrapoption was originally introduced to support the grid layout and ensure it works correctly with the flyout:includeSelectorInFocusTrap#8849The issue is that the Side Nav popovers appear after the flyout renders and evaluates
includeSelectorInFocusTrap. As a result, the selector isn’t accounted for in the focus trap, leading to focus getting lost:500894863-d25031df-c550-4291-91d3-a4efdc3df717.mp4
Implementation details
This is the core of
includeSelectorInFocusTrapfunctionality:eui/packages/eui/src/components/flyout/flyout.tsx
Lines 387 to 422 in d788096
The update reuses the same logic, passing it as a callback to the PubSub
subscribefunction. This lets the flyout detect when a publisher (e.g.EuiPopover) prompts a re-query of elements withincludeSelectorInFocusTrap.PubSub assures clear separation between
EuiPopoverandEuiFlyoutwithout linking them in any way together.Alternative approaches
onKeyDown:Causes a race condition. Key events fire before the popover renders, making focus updates asynchronous and unreliable (in Storybook it required a double Enter, in Kibana it lost focus). It could be made better by delaying the re-queries by popover transition but that is a fragile patch.
MutationObserver:Detects DOM changes but is costly and can hurt performance if not debounced. Can significantly impact the page load.
React Context API:
Works but adds unnecessary complexity and scope issues (requires global
EuiProvider, otherwise is limited to a certain subtree).Always publish
Always publishing from
EuiPopoveris the correct design here. A conditional prop would create a leaky abstraction, forcing developers to understand and manually connect the internal mechanics ofEuiPopoverandEuiFlyout.The current approach keeps the components decoupled: the popover announces its state and the flyout listens. The performance cost of this is negligible and ensures the components work together automatically.
Impact to users
🟢 This should not cause regression to Flyout trap focus and keyboard navigation. It should improve keyboard navigation in side nav and flyout composition in Kibana. No API changes.
QA
Specific checklist
Popover keyboard navigation:
Before merge:
Testing in Kibana
General checklist
Checked in both light and dark modesChecked in both MacOS and Windows high contrast modesChecked in mobileEdge, and FirefoxAdded documentationProps have proper autodocs (using@defaultif default values are missing) and playground togglesChecked Code Sandbox works for any docs examplesUpdated visual regression testsIf applicable, added the breaking change issue label (and filled out the breaking change checklist)If applicable, file an issue to update EUI's Figma library with any corresponding UI changes. (This is an internal repo, if you are external to Elastic, ask a maintainer to submit this request)