Skip to content

Commit 03ebf41

Browse files
colebemisjoshblack
andauthored
TreeView: Refactor loading focus management (#2868)
* Refactor loading focus management Co-authored-by: Josh Black <[email protected]> * Update spacing * Revert loading item id * Update src/TreeView/TreeView.tsx * Update src/TreeView/TreeView.tsx * Remove children from dep array * Create .changeset/chilly-rabbits-invent.md * Update TreeView tests --------- Co-authored-by: Josh Black <[email protected]>
1 parent 7c76b4a commit 03ebf41

File tree

3 files changed

+72
-55
lines changed

3 files changed

+72
-55
lines changed

.changeset/chilly-rabbits-invent.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+
TreeView: Reliably move focus from loading item to first asynchronously loaded item

src/TreeView/TreeView.test.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,11 @@ describe('Asyncronous loading', () => {
12301230
})
12311231

12321232
it('moves focus from loading item to first child', async () => {
1233+
// We get a focus zone warning in this test that doesn't
1234+
// happen in the browser. We're not sure why, so we're
1235+
// suppressing it for now.
1236+
jest.spyOn(console, 'warn').mockImplementation()
1237+
12331238
function TestTree() {
12341239
const [state, setState] = React.useState<SubTreeState>('loading')
12351240

@@ -1259,21 +1264,21 @@ describe('Asyncronous loading', () => {
12591264
act(() => {
12601265
// Focus first item
12611266
parentItem.focus()
1262-
1263-
// Press ↓ to move focus to loading item
1264-
fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'})
12651267
})
12661268

1269+
// Press ↓ to move focus to loading item
1270+
fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'})
1271+
12671272
// Loading item should be focused
12681273
expect(loadingItem).toHaveFocus()
12691274

1270-
// Wait for async loading to complete
1271-
const firstChild = await findByRole('treeitem', {name: 'Child 1'})
1272-
12731275
act(() => {
12741276
jest.runAllTimers()
12751277
})
12761278

1279+
// Wait for async loading to complete
1280+
const firstChild = await findByRole('treeitem', {name: 'Child 1'})
1281+
12771282
// First child should be focused
12781283
expect(firstChild).toHaveFocus()
12791284
})

src/TreeView/TreeView.tsx

Lines changed: 56 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import styled, {keyframes} from 'styled-components'
1010
import {get} from '../constants'
1111
import {ConfirmationDialog} from '../Dialog/ConfirmationDialog'
1212
import {useControllableState} from '../hooks/useControllableState'
13-
import useSafeTimeout from '../hooks/useSafeTimeout'
1413
import {useId} from '../hooks/useId'
1514
import Spinner from '../Spinner'
1615
import sx, {SxProp} from '../sx'
@@ -510,17 +509,10 @@ export type TreeViewSubTreeProps = {
510509
const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
511510
const {announceUpdate} = React.useContext(RootContext)
512511
const {itemId, isExpanded, isSubTreeEmpty, setIsSubTreeEmpty} = React.useContext(ItemContext)
513-
const [isLoadingItemVisible, setIsLoadingItemVisible] = React.useState(false)
514-
const {safeSetTimeout} = useSafeTimeout()
515512
const loadingItemRef = React.useRef<HTMLElement>(null)
516513
const ref = React.useRef<HTMLElement>(null)
517-
const [isPending, setPending] = React.useState(state === 'loading')
518-
519-
React.useEffect(() => {
520-
if (state === 'loading') {
521-
setPending(true)
522-
}
523-
}, [state])
514+
const [loadingFocused, setLoadingFocused] = React.useState(false)
515+
const previousState = usePreviousValue(state)
524516

525517
React.useEffect(() => {
526518
// If `state` is undefined, we're working in a synchronous context and need
@@ -536,61 +528,66 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
536528
}
537529
}, [state, isSubTreeEmpty, setIsSubTreeEmpty, children])
538530

539-
// If a consumer sets state="done" without having a previous state (like `loading`),
540-
// then it would announce on the first render. Using isPending is to only
541-
// announce being "loaded" when the state has changed from `loading` --> `done`.
531+
// Handle transition from loading to done state
542532
React.useEffect(() => {
543-
if (isPending && state === 'done') {
544-
const parentItem = document.getElementById(itemId)
533+
if (previousState === 'loading' && state === 'done') {
534+
const parentElement = document.getElementById(itemId)
535+
if (!parentElement) return
536+
537+
// Announce update to screen readers
538+
const parentName = getAccessibleName(parentElement)
545539

546-
if (!parentItem) return
540+
if (ref.current?.childElementCount) {
541+
announceUpdate(`${parentName} content loaded`)
542+
} else {
543+
announceUpdate(`${parentName} is empty`)
544+
}
547545

548-
const {current: node} = ref
549-
const parentName = getAccessibleName(parentItem)
546+
// Move focus to the first child if the loading indicator
547+
// was focused when the async items finished loading
548+
if (loadingFocused) {
549+
const firstChild = getFirstChildElement(parentElement)
550550

551-
safeSetTimeout(() => {
552-
if (node && node.childElementCount > 0) {
553-
announceUpdate(`${parentName} content loaded`)
551+
if (firstChild) {
552+
firstChild.focus()
554553
} else {
555-
announceUpdate(`${parentName} is empty`)
554+
parentElement.focus()
556555
}
557-
})
558556

559-
setPending(false)
557+
setLoadingFocused(false)
558+
}
560559
}
561-
}, [state, itemId, announceUpdate, safeSetTimeout, isPending])
560+
}, [loadingFocused, previousState, state, itemId, announceUpdate, ref])
562561

563-
// Manage loading indicator state
562+
// Track focus on the loading indicator
564563
React.useEffect(() => {
565-
// If we're in the loading state, but not showing the loading indicator yet,
566-
// show the loading indicator
567-
if (state === 'loading' && !isLoadingItemVisible) {
568-
setIsLoadingItemVisible(true)
564+
function handleFocus() {
565+
setLoadingFocused(true)
569566
}
570567

571-
// If we're not in the loading state, but we're still showing a loading indicator,
572-
// hide the loading indicator and move focus if necessary
573-
if (state !== 'loading' && isLoadingItemVisible) {
574-
const isLoadingItemFocused = document.activeElement === loadingItemRef.current
568+
function handleBlur(event: FocusEvent) {
569+
// Skip blur events that are caused by the element being removed from the DOM.
570+
// This can happen when the loading indicator is focused when async items are
571+
// done loading and the loading indicator is removed from the DOM.
572+
// If `loadingFocused` is `true` when `state` is `"done"` then the loading indicator
573+
// was focused when the async items finished loading and we need to move focus to the
574+
// first child.
575+
if (!event.relatedTarget) return
575576

576-
setIsLoadingItemVisible(false)
577+
setLoadingFocused(false)
578+
}
577579

578-
if (isLoadingItemFocused) {
579-
safeSetTimeout(() => {
580-
const parentElement = document.getElementById(itemId)
581-
if (!parentElement) return
580+
const loadingElement = loadingItemRef.current
581+
if (!loadingElement) return
582582

583-
const firstChild = getFirstChildElement(parentElement)
583+
loadingElement.addEventListener('focus', handleFocus)
584+
loadingElement.addEventListener('blur', handleBlur)
584585

585-
if (firstChild) {
586-
firstChild.focus()
587-
} else {
588-
parentElement.focus()
589-
}
590-
})
591-
}
586+
return () => {
587+
loadingElement.removeEventListener('focus', handleFocus)
588+
loadingElement.removeEventListener('blur', handleBlur)
592589
}
593-
}, [state, safeSetTimeout, isLoadingItemVisible, itemId])
590+
}, [loadingItemRef, state])
594591

595592
if (!isExpanded) {
596593
return null
@@ -607,13 +604,23 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
607604
// @ts-ignore Box doesn't have type support for `ref` used in combination with `as`
608605
ref={ref}
609606
>
610-
{isLoadingItemVisible ? <LoadingItem ref={loadingItemRef} count={count} /> : children}
607+
{state === 'loading' ? <LoadingItem ref={loadingItemRef} count={count} /> : children}
611608
</ul>
612609
)
613610
}
614611

615612
SubTree.displayName = 'TreeView.SubTree'
616613

614+
function usePreviousValue<T>(value: T): T {
615+
const ref = React.useRef(value)
616+
617+
React.useEffect(() => {
618+
ref.current = value
619+
}, [value])
620+
621+
return ref.current
622+
}
623+
617624
const shimmer = keyframes`
618625
from { mask-position: 200%; }
619626
to { mask-position: 0%; }

0 commit comments

Comments
 (0)