Skip to content

Commit ce5712c

Browse files
committed
feat(useFocusZone): update active descendant on mousemove
1 parent 6f0535d commit ce5712c

File tree

9 files changed

+125
-57
lines changed

9 files changed

+125
-57
lines changed

src/ActionList/Item.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import {StyledHeader} from './Header'
99
import {StyledDivider} from './Divider'
1010
import {useColorSchemeVar, useTheme} from '../ThemeProvider'
1111
import {uniqueId} from '../utils/uniqueId'
12+
import {
13+
activeDescendantActivatedDirectly,
14+
activeDescendantActivatedIndirectly,
15+
isActiveDescendantAttribute
16+
} from '../behaviors/focusZone'
1217

1318
/**
1419
* These colors are not yet in our default theme. Need to remove this once they are added.
@@ -121,8 +126,6 @@ export interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, '
121126
id?: number | string
122127
}
123128

124-
export const itemActiveDescendantClass = `${uniqueId()}active-descendant`
125-
126129
const getItemVariant = (variant = 'default', disabled?: boolean) => {
127130
if (disabled) {
128131
return {
@@ -212,16 +215,23 @@ const StyledItem = styled.div<
212215
// '*' instead of '&' because '&' maps to separate class names depending on 'variant'
213216
:focus + * ${StyledItemContent}::before,
214217
// - above Active Descendent
215-
&.${itemActiveDescendantClass} ${StyledItemContent}::before,
218+
&[${isActiveDescendantAttribute}] ${StyledItemContent}::before,
216219
// - below Active Descendent
217-
.${itemActiveDescendantClass} + & ${StyledItemContent}::before {
220+
[${isActiveDescendantAttribute}] + & ${StyledItemContent}::before {
218221
// '!important' because all the ':not's above give higher specificity
219222
border-color: transparent !important;
220223
}
221224
222-
// Focused OR Active Descendant
223-
&:focus,
224-
&.${itemActiveDescendantClass} {
225+
// Active Descendant
226+
&[${isActiveDescendantAttribute}='${activeDescendantActivatedDirectly}'] {
227+
background: ${({focusBackground}) => focusBackground};
228+
}
229+
&[${isActiveDescendantAttribute}='${activeDescendantActivatedIndirectly}'] {
230+
background: ${({hoverBackground}) => hoverBackground};
231+
}
232+
233+
// Focused
234+
&:focus {
225235
background: ${({focusBackground}) => focusBackground};
226236
outline: none;
227237
}

src/ActionList/List.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ function useListVariant(variant: ListProps['variant'] = 'inset'): {
132132
/**
133133
* Lists `Item`s, either grouped or ungrouped, with a `Divider` between each `Group`.
134134
*/
135-
export function List(props: ListProps): JSX.Element {
135+
export const List = React.forwardRef<HTMLDivElement, ListProps>((props, forwardedRef): JSX.Element => {
136136
// Get `sx` prop values for `List` children matching the given `List` style variation.
137137
const {firstGroupStyle, lastGroupStyle, headerStyle, itemStyle} = useListVariant(props.variant)
138138

@@ -216,7 +216,7 @@ export function List(props: ListProps): JSX.Element {
216216
}
217217

218218
return (
219-
<StyledList {...props}>
219+
<StyledList {...props} ref={forwardedRef}>
220220
{groups.map(({header, ...groupProps}, index) => {
221221
const hasFilledHeader = header?.variant === 'filled'
222222
const shouldShowDivider = index > 0 && !hasFilledHeader
@@ -242,4 +242,6 @@ export function List(props: ListProps): JSX.Element {
242242
})}
243243
</StyledList>
244244
)
245-
}
245+
})
246+
247+
List.displayName = 'ActionList'

src/FilteredActionList/FilteredActionList.tsx

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {ActionList} from '../ActionList'
77
import Spinner from '../Spinner'
88
import {useFocusZone} from '../hooks/useFocusZone'
99
import {uniqueId} from '../utils/uniqueId'
10-
import {itemActiveDescendantClass} from '../ActionList/Item'
1110
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
1211
import styled from 'styled-components'
1312
import {get} from '../constants'
@@ -71,6 +70,7 @@ export function FilteredActionList({
7170
[onFilterChange, setInternalFilterValue]
7271
)
7372

73+
const scrollContainerRef = useRef<HTMLDivElement>(null)
7474
const listContainerRef = useRef<HTMLDivElement>(null)
7575
const inputRef = useProvidedRefOrCreate<HTMLInputElement>(providedInputRef)
7676
const activeDescendantRef = useRef<HTMLElement>()
@@ -100,19 +100,11 @@ export function FilteredActionList({
100100
return true
101101
},
102102
activeDescendantFocus: inputRef,
103-
onActiveDescendantChanged: (current, previous) => {
103+
onActiveDescendantChanged: (current, previous, activatedByKeyboardNavigation) => {
104104
activeDescendantRef.current = current
105105

106-
if (previous) {
107-
previous.classList.remove(itemActiveDescendantClass)
108-
}
109-
110-
if (current) {
111-
current.classList.add(itemActiveDescendantClass)
112-
113-
if (listContainerRef.current) {
114-
scrollIntoViewingArea(current, listContainerRef.current)
115-
}
106+
if (current && scrollContainerRef.current && activatedByKeyboardNavigation) {
107+
scrollIntoViewingArea(current, scrollContainerRef.current)
116108
}
117109
}
118110
})
@@ -124,7 +116,7 @@ export function FilteredActionList({
124116
}
125117
}, [items])
126118

127-
useScrollFlash(listContainerRef)
119+
useScrollFlash(scrollContainerRef)
128120

129121
return (
130122
<Flex flexDirection="column" overflow="hidden">
@@ -143,13 +135,13 @@ export function FilteredActionList({
143135
{...textInputProps}
144136
/>
145137
</StyledHeader>
146-
<Box ref={listContainerRef} overflow="auto">
138+
<Box ref={scrollContainerRef} overflow="auto">
147139
{loading ? (
148140
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
149141
<Spinner />
150142
</Box>
151143
) : (
152-
<ActionList items={items} {...listProps} role="listbox" id={listId} />
144+
<ActionList ref={listContainerRef} items={items} {...listProps} role="listbox" id={listId} />
153145
)}
154146
</Box>
155147
</Flex>

src/__tests__/__snapshots__/ActionMenu.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ exports[`ActionMenu renders consistently 1`] = `
7070
<button
7171
aria-haspopup="listbox"
7272
aria-label="menu"
73-
aria-labelledby="__primer_id_10001"
73+
aria-labelledby="__primer_id_10002"
7474
className="c0"
75-
id="__primer_id_10001"
75+
id="__primer_id_10002"
7676
onClick={[Function]}
7777
onKeyDown={[Function]}
7878
tabIndex={0}

src/__tests__/__snapshots__/AnchoredOverlay.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ exports[`AnchoredOverlay renders consistently 1`] = `
6969
7070
<button
7171
aria-haspopup="listbox"
72-
aria-labelledby="__primer_id_10001"
72+
aria-labelledby="__primer_id_10002"
7373
className="c0"
74-
id="__primer_id_10001"
74+
id="__primer_id_10002"
7575
onClick={[Function]}
7676
onKeyDown={[Function]}
7777
tabIndex={0}

src/__tests__/__snapshots__/DropdownMenu.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ exports[`DropdownMenu renders consistently 1`] = `
7373
7474
<button
7575
aria-haspopup="listbox"
76-
aria-labelledby="__primer_id_10001"
76+
aria-labelledby="__primer_id_10002"
7777
className="c0"
78-
id="__primer_id_10001"
78+
id="__primer_id_10002"
7979
onClick={[Function]}
8080
onKeyDown={[Function]}
8181
tabIndex={0}

src/__tests__/__snapshots__/SelectPanel.tsx.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,9 @@ exports[`SelectPanel renders consistently 1`] = `
8585
>
8686
<button
8787
aria-haspopup="listbox"
88-
aria-labelledby="__primer_id_10001"
88+
aria-labelledby="__primer_id_10002"
8989
className="c1"
90-
id="__primer_id_10001"
90+
id="__primer_id_10002"
9191
onClick={[Function]}
9292
onKeyDown={[Function]}
9393
tabIndex={0}

src/__tests__/behaviors/focusZone.tsx

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react'
2-
import {render} from '@testing-library/react'
2+
import {fireEvent, render} from '@testing-library/react'
33
import userEvent from '@testing-library/user-event'
4-
import {FocusKeys, focusZone} from '../../behaviors/focusZone'
4+
import {FocusKeys, focusZone, FocusZoneSettings} from '../../behaviors/focusZone'
55

66
async function nextTick() {
77
return new Promise(resolve => setTimeout(resolve, 0))
@@ -419,24 +419,49 @@ it('Should call onActiveDescendantChanged properly', () => {
419419
activeDescendantControl: control,
420420
onActiveDescendantChanged: activeDescendantChangedCallback
421421
})
422+
type ActiveDescendantChangedCallbackParameters = Parameters<
423+
Exclude<FocusZoneSettings['onActiveDescendantChanged'], undefined>
424+
>
422425

423426
control.focus()
424-
userEvent.type(control, '{arrowdown}')
425-
expect(activeDescendantChangedCallback).toHaveBeenCalledWith<[HTMLElement | undefined, HTMLElement | undefined]>(
427+
expect(activeDescendantChangedCallback).toHaveBeenLastCalledWith<ActiveDescendantChangedCallbackParameters>(
426428
firstButton,
427-
undefined
429+
undefined,
430+
false
428431
)
429432
userEvent.type(control, '{arrowdown}')
430-
expect(activeDescendantChangedCallback).toHaveBeenCalledWith<[HTMLElement | undefined, HTMLElement | undefined]>(
433+
expect(activeDescendantChangedCallback).toHaveBeenLastCalledWith<ActiveDescendantChangedCallbackParameters>(
434+
secondButton,
435+
firstButton,
436+
true
437+
)
438+
userEvent.type(control, '{arrowup}')
439+
expect(activeDescendantChangedCallback).toHaveBeenLastCalledWith<ActiveDescendantChangedCallbackParameters>(
440+
firstButton,
441+
secondButton,
442+
true
443+
)
444+
fireEvent.mouseMove(secondButton)
445+
expect(activeDescendantChangedCallback).toHaveBeenLastCalledWith<ActiveDescendantChangedCallbackParameters>(
431446
secondButton,
432-
firstButton
447+
firstButton,
448+
false
433449
)
434450
userEvent.type(control, '{arrowup}')
451+
expect(activeDescendantChangedCallback).toHaveBeenLastCalledWith<ActiveDescendantChangedCallbackParameters>(
452+
firstButton,
453+
secondButton,
454+
true
455+
)
435456
userEvent.type(control, '{arrowUp}')
436-
expect(activeDescendantChangedCallback).toHaveBeenCalledWith<[HTMLElement | undefined, HTMLElement | undefined]>(
437-
undefined,
438-
firstButton
457+
expect(activeDescendantChangedCallback).toHaveBeenLastCalledWith<ActiveDescendantChangedCallbackParameters>(
458+
firstButton,
459+
firstButton,
460+
true
439461
)
462+
activeDescendantChangedCallback.mockReset()
463+
fireEvent.mouseMove(firstButton)
464+
expect(activeDescendantChangedCallback).not.toBeCalled()
440465

441466
controller.abort()
442467
})

src/behaviors/focusZone.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ export interface FocusZoneSettings {
189189
*/
190190
onActiveDescendantChanged?: (
191191
newActiveDescendant: HTMLElement | undefined,
192-
previousActiveDescendant: HTMLElement | undefined
192+
previousActiveDescendant: HTMLElement | undefined,
193+
directlyActivated: boolean
193194
) => void
194195

195196
/**
@@ -311,6 +312,10 @@ function shouldIgnoreFocusHandling(keyboardEvent: KeyboardEvent, activeElement:
311312
return false
312313
}
313314

315+
export const isActiveDescendantAttribute = 'data-is-active-descendant'
316+
export const activeDescendantActivatedDirectly = 'activated-directly'
317+
export const activeDescendantActivatedIndirectly = 'activated-indirectly'
318+
314319
/**
315320
* Sets up the arrow key focus behavior for all focusable elements in the given `container`.
316321
* @param container
@@ -337,13 +342,13 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
337342
return document.activeElement === activeDescendantControl
338343
}
339344

340-
function updateFocusedElement(to?: HTMLElement) {
345+
function updateFocusedElement(to?: HTMLElement, directlyActivated = false) {
341346
const from = currentFocusedElement
342347
currentFocusedElement = to
343348

344349
if (activeDescendantControl) {
345350
if (to && isActiveDescendantInputFocused()) {
346-
setActiveDescendant(from, to)
351+
setActiveDescendant(from, to, directlyActivated)
347352
} else {
348353
clearActiveDescendant()
349354
}
@@ -358,13 +363,29 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
358363
to?.setAttribute('tabindex', '0')
359364
}
360365

361-
function setActiveDescendant(from: HTMLElement | undefined, to: HTMLElement) {
366+
function setActiveDescendant(from: HTMLElement | undefined, to: HTMLElement, directlyActivated = false) {
362367
if (!to.id) {
363368
to.setAttribute('id', uniqueId())
364369
}
365370

366-
activeDescendantControl?.setAttribute('aria-activedescendant', to.id)
367-
activeDescendantCallback?.(to, from === to ? undefined : from)
371+
if (from && from !== to) {
372+
from.removeAttribute(isActiveDescendantAttribute)
373+
}
374+
375+
if (
376+
!activeDescendantControl ||
377+
(!directlyActivated && activeDescendantControl.getAttribute('aria-activedescendant') === to.id)
378+
) {
379+
// prevent active descendant callback from being called repeatedly if the same element is activated (e.g. via mousemove)
380+
return
381+
}
382+
383+
activeDescendantControl.setAttribute('aria-activedescendant', to.id)
384+
to.setAttribute(
385+
isActiveDescendantAttribute,
386+
directlyActivated ? activeDescendantActivatedDirectly : activeDescendantActivatedIndirectly
387+
)
388+
activeDescendantCallback?.(to, from, directlyActivated)
368389
}
369390

370391
function clearActiveDescendant(previouslyActiveElement = currentFocusedElement) {
@@ -373,7 +394,8 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
373394
}
374395

375396
activeDescendantControl?.removeAttribute('aria-activedescendant')
376-
activeDescendantCallback?.(undefined, previouslyActiveElement)
397+
previouslyActiveElement?.removeAttribute(isActiveDescendantAttribute)
398+
activeDescendantCallback?.(undefined, previouslyActiveElement, false)
377399
}
378400

379401
function beginFocusManagement(...elements: HTMLElement[]) {
@@ -484,6 +506,23 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
484506
updateFocusedElement(event.target)
485507
}
486508
})
509+
container.addEventListener(
510+
'mousemove',
511+
({target}) => {
512+
if (!(target instanceof HTMLElement)) {
513+
return
514+
}
515+
516+
const focusableElement = focusableElements.find(element => element.contains(target))
517+
518+
if (focusableElement) {
519+
updateFocusedElement(focusableElement)
520+
}
521+
},
522+
{signal, capture: true}
523+
)
524+
525+
// Listeners specifically on the controlling element
487526
activeDescendantControl.addEventListener('focusin', () => {
488527
// Focus moved into the active descendant input. Activate current or first descendant.
489528
if (!currentFocusedElement) {
@@ -637,13 +676,13 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
637676
}
638677
}
639678

640-
if (nextElementToFocus) {
641-
if (activeDescendantControl) {
642-
updateFocusedElement(nextElementToFocus)
643-
} else {
644-
lastKeyboardFocusDirection = direction
645-
nextElementToFocus.focus()
646-
}
679+
if (activeDescendantControl) {
680+
updateFocusedElement(nextElementToFocus || currentFocusedElement, true)
681+
} else if (nextElementToFocus) {
682+
lastKeyboardFocusDirection = direction
683+
684+
// updateFocusedElement will be called implicitly when focus moves, as long as the event isn't prevented somehow
685+
nextElementToFocus.focus()
647686
}
648687
// Tab should always allow escaping from this container, so only
649688
// preventDefault if tab key press already resulted in a focus movement

0 commit comments

Comments
 (0)