-
Notifications
You must be signed in to change notification settings - Fork 841
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(tabs): traverse tabs with arrow keys #8116
base: main
Are you sure you want to change the base?
feat(tabs): traverse tabs with arrow keys #8116
Conversation
fa2af92
to
e4511ae
Compare
c89bb30
to
bb63b1c
Compare
const handleTabStop = (event: KeyboardEvent) => { | ||
const currentRef = elementRef.current; | ||
const tabs = currentRef?.querySelectorAll('[role="tab"]'); | ||
if (!tabs) return; |
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.
if (!tabs) return; | |
if (!tabs.length) return; |
querySelectorAll
will return an empty list/array which passes a truthy check, so this current early return won't work unless we check the length
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.
You're correct! I was focusing on safe-guarding against elementRef.current
being null
but didn't think of an empty array. Thanks, Cee! 🙌🏻 I'll do:
if (!tabs) return; | |
if (!tabs?.length) return; |
const handleKeyDown = (event: KeyboardEvent) => { | ||
const currentEl = elementRef.current; | ||
|
||
const tabs = currentEl?.querySelectorAll('[role="tab"]'); |
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 actually really like querying for the tab
role (as opposed to using our tabbable library. It's possible for consumers to insert wildcard children into <EuiTabs>
(although it's not accessibly correct to do so), so this query selector ensures that only the correct tab children receive arrow key navigation.
That being said, I'd consider modifying this selector to exclude disabled and inert tabs, so they're not included in arrow key navigation:
const tabs = currentEl?.querySelectorAll('[role="tab"]'); | |
const tabs = currentEl?.querySelectorAll('[role="tab"]:not(:disabled, [inert])'); |
The tabbable library additionally checks for visibility: hidden
or display: none
, but IMO we don't need to go that far here for those edge 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.
Oh, also worth noting you can avoid the as
casting below by passing a generic to querySelectorAll
:
const tabs = currentEl?.querySelectorAll('[role="tab"]'); | |
const tabs = currentEl?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled, [inert])'); |
(prettier will probably have Opinions about splitting this into multiple lines at that point, so deliberately leaving the indentation wrong on this suggestion)
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.
useEffect(() => { | ||
const currentEl = elementRef.current; | ||
|
||
currentEl?.addEventListener('keydown', handleKeyDown); | ||
|
||
return () => currentEl?.removeEventListener('keydown', handleKeyDown); | ||
}, []); |
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.
Why add this as a useEffect
as opposed to adding an onKeyDown={handleKeyDown}
onto the div?
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.
@cee-chen no idea, honestly. Might've been too much in the vanilla JS context after reading MDN docs. Event handler is a much cleaner solution, I'll do that 👌🏻 Thanks, Cee!
@@ -67,9 +75,36 @@ export const EuiTabs = forwardRef<EuiTabRef, EuiTabsProps>( | |||
bottomBorder && styles.bottomBorder, | |||
]; | |||
|
|||
const handleKeyDown = (event: KeyboardEvent) => { |
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.
[microperf] We should memoize this in a useCallback so it's not reinstantiated every rerender
const handleKeyDown = (event: KeyboardEvent) => { | |
const handleKeyDown = useCallback((event: KeyboardEvent) => { |
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.
@cee-chen handleKeyDown
doesn't perform any heavy calculations. It's not used in the useEffect's dependency array and it's not passed to a memo
-ed component. Re-rendering a bunch of tabs doesn't seem too expensive either. And memoizing a function comes at a cost.
That being said, I'll test the performance in Profiler with and without the useCallback, share the results and we can decide together whether to go for it or skip it. Unless you're strong on this? Or I won't, I don't know what's up with React DevTools recently but Profiler tab doesn't show for me 😢
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.
Can we add a Cypress test checking that this still works if consumers completely change their tabs dynamically? Or what happens if they dynamically disable some tabs?
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.
@cee-chen that's a very good idea! I didn't even think of a dynamic tab use case. Could you share an example from Kibana? Or maybe you have an idea of what that test could look like?
); | ||
|
||
if (event.key === keys.ARROW_LEFT) { | ||
const previousIndex = (currentIndex - 1 + tabs.length) % tabs.length; |
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've never seen this shortcut math for looping back around to the first/last item in the array before 🤯 Can we add a quick inline code comment here explaining what's happening? e.g.,
const previousIndex = (currentIndex - 1 + tabs.length) % tabs.length; | |
const previousIndex = (currentIndex - 1 + tabs.length) % tabs.length; // % loops back to the last item in the array |
+ same for the nextIndex
%
logic
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.
@cee-chen A neat math trick ✨ but you're right, it's not too clear. We could just do:
const previousIndex = (currentIndex === 0 ? tabs.length : currentIndex) - 1;
and
const nextIndex = currentIndex === tabs.length ? 0 : currentIndex + 1
What do you think?
a170f27
to
63b23a6
Compare
63b23a6
to
a0cb507
Compare
Preview staging links for this PR:
|
💚 Build Succeeded
History
|
Summary
Both the WCAG and MDN specifications for tabs specify that they should be within a tab group and traversed via arrow keys.
Screen.Recording.2024-11-05.at.12.41.35.mov
I've noticed there are 2 ways the tabs are used:
tabpanel
; EuiTabs and EuiTab accessible from the same level),I decided to put the logic inside EuiTabs as its the common point between the 2 use cases and it makes sense for that wrapper to traverse the nodes within and manipulate the DOM (as opposed to traversing them inside EuiTab from the
parentElement
or handling in EuiTabbedContent).closes #8005
QA
I tested the changes in the Storybook, the docs and against Kibana (the provided use case: Main Menu > Management > Dev Tools) with VoiceOver.
General checklist
Checked in both light and dark modesEdge, and FirefoxAdded documentationProps have proper autodocs (using@default
if default values are missing) and playground togglesChecked Code Sandbox works for any docs examplesUpdated visual regression tests(not applicable)If 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)