-
Notifications
You must be signed in to change notification settings - Fork 131
[UEPR-445] Ensure topbar is navigable via tab navigation #403
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
base: develop
Are you sure you want to change the base?
[UEPR-445] Ensure topbar is navigable via tab navigation #403
Conversation
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.
Pull request overview
This PR implements keyboard navigation and accessibility improvements for the top navigation bar, enabling users to navigate menu items using Tab and arrow keys. The changes introduce a new context-based system for tracking open menus and managing focus states across nested menu hierarchies.
Key Changes:
- Added
tabIndexattributes and ARIA properties to make menu bar elements keyboard-navigable - Implemented arrow key navigation within dropdown menus (Settings, Language, Theme)
- Created
MenuRefContextto manage the state of open menus and focus tracking across the menu hierarchy
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/scratch-gui/src/components/context-menu/menu-path-context.jsx | New context provider for tracking open menu references and navigation state |
| packages/scratch-gui/src/components/menu-bar/settings-menu.jsx | Converted to class component with keyboard navigation handlers |
| packages/scratch-gui/src/components/menu-bar/language-menu.jsx | Added keyboard navigation with arrow keys and Enter/Escape handlers |
| packages/scratch-gui/src/components/menu-bar/theme-menu.jsx | Added keyboard navigation similar to language menu |
| packages/scratch-gui/src/components/menu/menu.jsx | Added accessibility props (focusedRef, aria attributes, keyboard handlers) |
| packages/scratch-gui/src/components/menu-bar/menu-bar.jsx | Added tabIndex and ARIA attributes to menu bar items for keyboard access |
| packages/scratch-gui/src/components/gui/gui.jsx | Wrapped MenuBar with MenuRefProvider context |
| packages/scratch-gui/src/containers/gui.jsx | Contains commented test code |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/scratch-gui/src/components/context-menu/menu-path-context.jsx
Outdated
Show resolved
Hide resolved
| ref={menuRef} | ||
| aria-label={ariaLabel} | ||
| aria-selected={isSelected ?? null} | ||
| aria-disabled={isDisabled ?? null} |
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.
So this applies for both aria-selected and aria-disabled.
They seem to require specific roles for both itself and its container, such as role="option" inside role="listbox", or role="tab" inside role="tablist". For some reason I couldn't really get it to work. But it might be due to my limited screen reader. I'll try to figure out why that is.
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.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
KManolov3
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.
Really like how it's turning out with the hook. The accessibility behaviour logic looks quite encapsulated, which means we can test it and make global changes to the behavior easily!
packages/scratch-gui/src/components/menu-bar/preference-menu.jsx
Outdated
Show resolved
Hide resolved
| */ | ||
| export default function useMenuNavigation ({ | ||
| menuRef, | ||
| itemRefs, |
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.
The one thing that's responsible for a lot of boilerplate code in the components using this hook is the itemRefs param, especially in the places where the itemRefs need to be defined manually.
I wonder if we can reach a state where we don't need to pass them explicitly. One thing we could do is pass a data prop to the selectable menu items (e.g. pass data-menu-item=true as a prop) and then use it here to select all children items of the menu.
const MENU_ITEM_SELECTOR = '[data-menu-item="true"]';
...
const handleOnOpen = useCallback(() => {
if (menuContext.isOpenMenu(menuRef)) return;
menuContext.openInnerMenu(menuRef, depth);
// Wait for the UI to be rendered before interacting with the DOM
requestAnimationFrame(() => {
// Select all the children of the menu marked as items
const items = menuRef.current?.querySelectorAll(MENU_ITEM_SELECTOR);
if (items && items[defaultIndexOnOpen]) {
items[defaultIndexOnOpen].focus();
}
});
}, [menuContext, menuRef, depth, defaultIndexOnOpen]);
const handleMove = useCallback(direction => {
if (!menuRef.current) return;
const items = Array.from(menuRef.current.querySelectorAll(MENU_ITEM_SELECTOR));
if (items.length === 0) return;
// Maybe we can use the document's active element to check what's the currently active element,
// instead of keeping it in state with `focusedIndex` ?
const currentIndex = items.indexOf(document.activeElement);
const nextIndex = (currentIndex + direction + items.length) % items.length;
items[nextIndex].focus();
}, [menuRef]);
...etc
The submenus will still need their own refs, but they could be created in the components themselves (e.g. fileMenu can create its own menuRef to use for useMenuNavigation, instead of it being passed from outside.
I'm not completely sure how this will work in practice (e.g. if we'll end up with some race conditions due to the DOM rendering), but it sounds workable to me in theory.
What do you think?
cc: @adzhindzhi, I'd like to hear your thoughts as well
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 definitely agree that reducing the boilerplate around itemRefs would be nice, but I’m not entirely sure querying the DOM is the best way to do it. It feels a bit hacky/fragile and depends on things already being rendered. That said, we could try it out and see if any issues come up in practice.
Another direction I was thinking of was keeping the hook ref-based, but letting MenuItem create its own ref and register it through the hook (by exposing a register method or something similar), instead of passing refs around from the outside. That might let us get rid of the manual ref arrays without relying on DOM queries. This is more of a thought than a strong opinion, though, and I'm not sure if something like that would actually be applicable, considering various edge cases, etc., so I'm definitely fine with trying out the data-prop approach instead.
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.
Another direction I was thinking of was keeping the hook ref-based, but letting MenuItem create its own ref and register it through the hook
That was a direction I was also thinking in, but I'm not sure whether that guarantees us the correct order of items, especially in cases where one of the non-last items depends on a (potentially async) condition
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.
@adzhindzhi if menu items were to create their own ref, I'm assuming you want to pass down the register method that the hook returns from the above menu? If so, I think I like that approach better since it's more React-esque and would solve the manual itemRefs declaring (which also has some repeated logic for conditional rendering which a maintainer might miss to alter)
cc: @KManolov3 would you agree it's better than the query selector approach?
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 was more so thinking about using the hook in MenuItem itself and avoiding passing the function, if possible. But Kalo’s right that we may run into issues with the order of items, so we’d need to be careful there as well.
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 think the correctness of the item order is what worries me w/ the MenuItem "registration" approach. Feel free to experiment locally with it.
Otherwise, I think I'd give the data-attribute approach a try; it may not be the prettiest, but, unless there are async issues with waiting for the DOM to be rendered, which can't be reliably addressed with something like requestAnimationFrame, it should get the job done and reduce boilerplate.
If either of those don't work, or end up revealing unexpected complexity, I'm okay with logging a task to address this in a later PR.
| */ | ||
| export default function useMenuNavigation ({ | ||
| menuRef, | ||
| itemRefs, |
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 definitely agree that reducing the boilerplate around itemRefs would be nice, but I’m not entirely sure querying the DOM is the best way to do it. It feels a bit hacky/fragile and depends on things already being rendered. That said, we could try it out and see if any issues come up in practice.
Another direction I was thinking of was keeping the hook ref-based, but letting MenuItem create its own ref and register it through the hook (by exposing a register method or something similar), instead of passing refs around from the outside. That might let us get rid of the manual ref arrays without relying on DOM queries. This is more of a thought than a strong opinion, though, and I'm not sure if something like that would actually be applicable, considering various edge cases, etc., so I'm definitely fine with trying out the data-prop approach instead.
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.
Pull request overview
Copilot reviewed 25 out of 25 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/scratch-gui/src/components/menu-bar/preference-menu.jsx
Outdated
Show resolved
Hide resolved
|
FYI, there are some conflicts which we need to resolve before merging. |
| <div className={styles.titleAuthor}> | ||
| <div | ||
| className={styles.titleAuthor} | ||
| tabIndex={0} |
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.
Hm, that's not an interactive element, do we want it to be focusable? Screen readers should be able to read it even without the tabIndex. Also I am not entirely sure we need the aria-label, screen readers would read the rendered text, which is projectTitle + username anyway?
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.
Screen readers should be able to read it even without the
tabIndex
Yes. But what I hadn't noticed was that it was a landmark. I don't think it should be. I changed the role to presentation which should not be read by good screen readers. You are welcome to suggest a better one.
As for the aria-label, I don't see harm in customizing the way that is read - it goes from "ProjectNameby username" to "Project ProjectName by username".
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.
Judging by the contentinfo role description, I don’t think it’s a good fit here. It’s intended for page-level footer content, so it probably makes sense to remove the role altogether (as I don't see anything else that could be a fit).
Regarding tabIndex, it’s not related to what screen readers announce. It only controls whether an element is focusable. Since this element isn’t interactive, it shouldn’t be focusable.
As for aria-label, I don’t think it’s a good practice to use it on non-interactive elements when the content is already readable by screen readers. While I understand the intent to improve how it’s announced, it seems more appropriate to fix the rendered text itself (for example by adding {' '}), rather than overriding it with an aria-label when it’s not necessary. I’m not strongly opposed to leaving it as-is, but I believe it’s unnecessary.
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.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| aria-label={this.props.intl.formatMessage(ariaMessages.home)} | ||
| tabIndex={0} |
Copilot
AI
Jan 19, 2026
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.
An img element with role=\"button\", tabIndex, and aria-label should be replaced with an actual button element containing the image for better semantic HTML and consistent keyboard interaction patterns.
| <span className={styles.profileName}> | ||
| {username} | ||
| </span> | ||
| <div className={styles.dropdownCaretPosition}> |
Copilot
AI
Jan 19, 2026
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.
Inconsistent style import usage. The file imports both styles (from account-menu.css) and stylesMenuBar (from menu-bar.css), but dropdownCaretIcon is referenced from stylesMenuBar while other nearby elements use styles. Consider consolidating styles or documenting why this specific class comes from a different stylesheet.
| <div className={styles.dropdownCaretPosition}> | |
| <div className={styles.dropdownCaretPosition}> | |
| {/* Use shared menu bar styling for the caret icon to keep its appearance consistent | |
| * with other menu bar components. The surrounding layout uses account-menu styles. | |
| */} |
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.
The most I'll go is rename styles to stylesAccountMenu. Comments seem like overkill
| <div | ||
| className={styles.titleAuthor} | ||
| tabIndex={0} | ||
| role="presentation" |
Copilot
AI
Jan 19, 2026
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.
The element has tabIndex={0} making it focusable, but role=\"presentation\" indicates it should be ignored by assistive technologies. These attributes conflict. If the element should be focusable and announced by screen readers (which the aria-label suggests), use a semantic element like button or a more appropriate role like region or group.
| role="presentation" | |
| role="group" |
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.
perhaps it's onto something, I was wondering what role i should give it.
Aims to resolve UEPR-445
https://scratchfoundation.atlassian.net/browse/UEPR-445
Proposed Changes
To be done additionally concerning nav bar accessibility - in other PRs?
To be discussed additionally
Bugs - currently testing