Skip to content

Commit 5d06738

Browse files
authored
Jdrush89/tree focus bug (#3300)
* Adding example of focus bug * Not using focusInStrategy when clicking * Clearing mousedown state correctly * Adding changeset * Removing unused import * Moving mouseup handler
1 parent ac698b5 commit 5d06738

File tree

5 files changed

+185
-7
lines changed

5 files changed

+185
-7
lines changed

.changeset/khaki-walls-applaud.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
Fixing toggle bug on Treeview when it initially receives focus

src/TreeView/TreeView.features.stories.tsx

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -777,11 +777,118 @@ export const InitialFocus: Story = () => (
777777
<div>
778778
<Button>Focusable element before TreeView</Button>
779779
<TreeView aria-label="Test tree">
780-
<TreeView.Item id="item-1">Item 1</TreeView.Item>
781-
<TreeView.Item id="item-2" current>
782-
Item 2
780+
<TreeView.Item id="src" defaultExpanded>
781+
<TreeView.LeadingVisual>
782+
<TreeView.DirectoryIcon />
783+
</TreeView.LeadingVisual>
784+
src
785+
<TreeView.SubTree>
786+
<TreeView.Item id="src/Avatar.tsx">
787+
<TreeView.LeadingVisual>
788+
<FileIcon />
789+
</TreeView.LeadingVisual>
790+
Avatar.tsx
791+
</TreeView.Item>
792+
<TreeView.Item id="src/Button" defaultExpanded>
793+
<TreeView.LeadingVisual>
794+
<TreeView.DirectoryIcon />
795+
</TreeView.LeadingVisual>
796+
Button
797+
<TreeView.SubTree>
798+
<TreeView.Item id="src/Button/Button.tsx">
799+
<TreeView.LeadingVisual>
800+
<FileIcon />
801+
</TreeView.LeadingVisual>
802+
Button.tsx
803+
</TreeView.Item>
804+
<TreeView.Item id="src/Button/Button.test.tsx">
805+
<TreeView.LeadingVisual>
806+
<FileIcon />
807+
</TreeView.LeadingVisual>
808+
Button.test.tsx
809+
</TreeView.Item>
810+
<TreeView.Item id="src/Button/Button2.tsx">
811+
<TreeView.LeadingVisual>
812+
<FileIcon />
813+
</TreeView.LeadingVisual>
814+
Button2.tsx
815+
</TreeView.Item>
816+
<TreeView.Item id="src/Button/Button2.test.tsx">
817+
<TreeView.LeadingVisual>
818+
<FileIcon />
819+
</TreeView.LeadingVisual>
820+
Button2.test.tsx
821+
</TreeView.Item>
822+
<TreeView.Item id="src/Button/Button3.tsx">
823+
<TreeView.LeadingVisual>
824+
<FileIcon />
825+
</TreeView.LeadingVisual>
826+
Button3.tsx
827+
</TreeView.Item>
828+
<TreeView.Item id="src/Button/Button3.test.tsx">
829+
<TreeView.LeadingVisual>
830+
<FileIcon />
831+
</TreeView.LeadingVisual>
832+
Button3.test.tsx
833+
</TreeView.Item>
834+
<TreeView.Item id="src/Button/Button4.tsx">
835+
<TreeView.LeadingVisual>
836+
<FileIcon />
837+
</TreeView.LeadingVisual>
838+
Button4.tsx
839+
</TreeView.Item>
840+
<TreeView.Item id="src/Button/Button4.test.tsx">
841+
<TreeView.LeadingVisual>
842+
<FileIcon />
843+
</TreeView.LeadingVisual>
844+
Button4.test.tsx
845+
</TreeView.Item>
846+
<TreeView.Item id="src/Button/Button5.tsx">
847+
<TreeView.LeadingVisual>
848+
<FileIcon />
849+
</TreeView.LeadingVisual>
850+
Button5.tsx
851+
</TreeView.Item>
852+
<TreeView.Item id="src/Button/Button5.test.tsx">
853+
<TreeView.LeadingVisual>
854+
<FileIcon />
855+
</TreeView.LeadingVisual>
856+
Button5.test.tsx
857+
</TreeView.Item>
858+
<TreeView.Item id="src/Button/Button6.tsx">
859+
<TreeView.LeadingVisual>
860+
<FileIcon />
861+
</TreeView.LeadingVisual>
862+
Button6.tsx
863+
</TreeView.Item>
864+
<TreeView.Item id="src/Button/Button6.test.tsx">
865+
<TreeView.LeadingVisual>
866+
<FileIcon />
867+
</TreeView.LeadingVisual>
868+
Button6.test.tsx
869+
</TreeView.Item>
870+
<TreeView.Item id="src/Button/Button7.tsx">
871+
<TreeView.LeadingVisual>
872+
<FileIcon />
873+
</TreeView.LeadingVisual>
874+
Button7.tsx
875+
</TreeView.Item>
876+
<TreeView.Item id="src/Button/Button7.test.tsx" current>
877+
<TreeView.LeadingVisual>
878+
<FileIcon />
879+
</TreeView.LeadingVisual>
880+
Button7.test.tsx
881+
</TreeView.Item>
882+
</TreeView.SubTree>
883+
</TreeView.Item>
884+
<TreeView.Item id="src/ReallyLongFileNameThatShouldBeTruncated.tsx">
885+
<TreeView.LeadingVisual>
886+
<FileIcon />
887+
</TreeView.LeadingVisual>
888+
ReallyLongFileNameThatShouldBeTruncated.tsx
889+
</TreeView.Item>
890+
</TreeView.SubTree>
783891
</TreeView.Item>
784-
<TreeView.Item id="item-3">Item 3</TreeView.Item>
785892
</TreeView>
786893
<Button>Focusable element after TreeView</Button>
787894
</div>

src/TreeView/TreeView.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,43 @@ describe('Markup', () => {
247247
const item2 = getByRole('treeitem', {name: /Item 2/})
248248
expect(item2).toHaveFocus()
249249
})
250+
251+
it('should toggle when receiving focus from chevron click', async () => {
252+
const user = userEvent.setup({delay: null})
253+
const {getByRole} = renderWithTheme(
254+
<div>
255+
<button>Focusable element</button>
256+
<TreeView aria-label="Test tree">
257+
<TreeView.Item id="item-1">
258+
Item 1
259+
<TreeView.SubTree>
260+
<TreeView.Item id="subitem-1">SubItem 1</TreeView.Item>
261+
<TreeView.Item id="subitem-2">SubItem 2</TreeView.Item>
262+
<TreeView.Item id="subitem-3">SubItem 3</TreeView.Item>
263+
</TreeView.SubTree>
264+
</TreeView.Item>
265+
<TreeView.Item id="item-2" current>
266+
Item 2
267+
</TreeView.Item>
268+
<TreeView.Item id="item-3">Item 3</TreeView.Item>
269+
</TreeView>
270+
</div>,
271+
)
272+
273+
// Focus button
274+
const button = getByRole('button', {name: /Focusable element/})
275+
await user.click(button)
276+
expect(button).toHaveFocus()
277+
278+
// Move focus to tree
279+
const item1 = getByRole('treeitem', {name: /Item 1/})
280+
const toggle = item1.querySelector('.PRIVATE_TreeView-item-toggle')
281+
await user.click(toggle!)
282+
283+
// Focus should be on current treeitem
284+
const subItem1 = getByRole('treeitem', {name: /SubItem 1/})
285+
expect(subItem1).toBeInTheDocument()
286+
})
250287
})
251288

252289
describe('Keyboard interactions', () => {

src/TreeView/TreeView.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
FileDirectoryOpenFillIcon,
66
} from '@primer/octicons-react'
77
import classnames from 'classnames'
8-
import React from 'react'
8+
import React, {useCallback, useEffect} from 'react'
99
import styled, {keyframes} from 'styled-components'
1010
import {ConfirmationDialog} from '../Dialog/ConfirmationDialog'
1111
import Spinner from '../Spinner'
@@ -256,12 +256,27 @@ const Root: React.FC<TreeViewProps> = ({
256256
flat,
257257
}) => {
258258
const containerRef = React.useRef<HTMLUListElement>(null)
259+
const mouseDownRef = React.useRef<boolean>(false)
259260
const [ariaLiveMessage, setAriaLiveMessage] = React.useState('')
260261
const announceUpdate = React.useCallback((message: string) => {
261262
setAriaLiveMessage(message)
262263
}, [])
263264

264-
useRovingTabIndex({containerRef})
265+
const onMouseDown = useCallback(() => {
266+
mouseDownRef.current = true
267+
}, [])
268+
269+
useEffect(() => {
270+
function onMouseUp() {
271+
mouseDownRef.current = false
272+
}
273+
document.addEventListener('mouseup', onMouseUp)
274+
return () => {
275+
document.removeEventListener('mouseup', onMouseUp)
276+
}
277+
}, [])
278+
279+
useRovingTabIndex({containerRef, mouseDownRef})
265280
useTypeahead({
266281
containerRef,
267282
onFocusChange: element => {
@@ -294,6 +309,7 @@ const Root: React.FC<TreeViewProps> = ({
294309
aria-label={ariaLabel}
295310
aria-labelledby={ariaLabelledby}
296311
data-omit-spacer={flat}
312+
onMouseDown={onMouseDown}
297313
>
298314
{children}
299315
</UlBox>

src/TreeView/useRovingTabIndex.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import React from 'react'
22
import {FocusKeys, useFocusZone} from '../hooks/useFocusZone'
33
import {getScrollContainer} from '../utils/scroll'
44

5-
export function useRovingTabIndex({containerRef}: {containerRef: React.RefObject<HTMLElement>}) {
5+
export function useRovingTabIndex({
6+
containerRef,
7+
mouseDownRef,
8+
}: {
9+
containerRef: React.RefObject<HTMLElement>
10+
mouseDownRef: React.RefObject<boolean>
11+
}) {
612
// TODO: Initialize focus to the aria-current item if it exists
713
useFocusZone({
814
containerRef,
@@ -19,6 +25,13 @@ export function useRovingTabIndex({containerRef}: {containerRef: React.RefObject
1925
return getNextFocusableElement(from, event) ?? from
2026
},
2127
focusInStrategy: () => {
28+
// Don't try to execute the focusInStrategy if focus is coming from a click.
29+
// The clicked row will receive focus correctly by default.
30+
// If a chevron is clicked, setting the focus through the focuszone will prevent its toggle.
31+
if (mouseDownRef.current) {
32+
return undefined
33+
}
34+
2235
const currentItem = containerRef.current?.querySelector('[aria-current]')
2336
const firstItem = containerRef.current?.querySelector('[role="treeitem"]')
2437

0 commit comments

Comments
 (0)