fix(wizard): focus next step content#2372
Conversation
Focus the first tabbable control after advancing a step, falling back to the next button when the step has no tabbable control. Closes #2346
There was a problem hiding this comment.
Code Review
This pull request implements automatic focus management when navigating between wizard steps. When moving to the next step, the wizard now attempts to focus the first interactive (tabbable) element within that step, falling back to focusing the next button if no interactive elements are found. Unit tests have been added to verify this behavior. The reviewer suggested optimizing the DOM traversal in getFirstTabbableElement by using querySelectorAll('*') instead of recursive traversal.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private getFirstTabbableElement(root: HTMLElement): HTMLElement | undefined { | ||
| if (this.interactivityChecker.isFocusable(root) && this.interactivityChecker.isTabbable(root)) { | ||
| return root; | ||
| } | ||
|
|
||
| for (const child of Array.from(root.children)) { | ||
| const tabbableChild = this.getFirstTabbableElement(child as HTMLElement); | ||
| if (tabbableChild) { | ||
| return tabbableChild; | ||
| } | ||
| } | ||
|
|
||
| return undefined; | ||
| } |
There was a problem hiding this comment.
Instead of recursively traversing the DOM tree and creating a new array with Array.from(root.children) at each level, you can use root.querySelectorAll('*') to retrieve all descendant elements in document order (depth-first pre-order traversal). This is more efficient, avoids recursion overhead, and simplifies the implementation.
| private getFirstTabbableElement(root: HTMLElement): HTMLElement | undefined { | |
| if (this.interactivityChecker.isFocusable(root) && this.interactivityChecker.isTabbable(root)) { | |
| return root; | |
| } | |
| for (const child of Array.from(root.children)) { | |
| const tabbableChild = this.getFirstTabbableElement(child as HTMLElement); | |
| if (tabbableChild) { | |
| return tabbableChild; | |
| } | |
| } | |
| return undefined; | |
| } | |
| private getFirstTabbableElement(root: HTMLElement): HTMLElement | undefined { | |
| if (this.interactivityChecker.isFocusable(root) && this.interactivityChecker.isTabbable(root)) { | |
| return root; | |
| } | |
| const descendants = root.querySelectorAll<HTMLElement>('*'); | |
| for (let i = 0; i < descendants.length; i++) { | |
| const el = descendants[i]; | |
| if (this.interactivityChecker.isFocusable(el) && this.interactivityChecker.isTabbable(el)) { | |
| return el; | |
| } | |
| } | |
| return undefined; | |
| } |
| } | ||
|
|
||
| private focusStepContent(): void { | ||
| afterNextRender(() => { |
There was a problem hiding this comment.
how about just using FocusTrapFactory
| afterNextRender(() => { | |
| const ff = this.focusFactory.create(this.currentStep?.elementRef.nativeElement) | |
| ff.focusFirstTabbableElementWhenReady().then((focused) => { | |
| if (!focused) { | |
| this.nextButton()?.nativeElement?.focus(); | |
| } | |
| ff.destroy(); | |
| }); |
Focus the first tabbable control after advancing a step, falling back to the next button when the step has no tabbable control.\n\nCloses #2346
Documentation.
Examples.
Dashboards Demo.
Playwright report.
Coverage Reports: