Skip to content

Commit f499d79

Browse files
dgreifcolebemis
authored andcommitted
feat(useFocusZone): update active-descendant on mousemove (#1308)
1 parent 7f99eb6 commit f499d79

File tree

11 files changed

+172
-67
lines changed

11 files changed

+172
-67
lines changed

.changeset/funny-birds-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/components": patch
3+
---
4+
5+
Focus zones will now update active-descendant on `mousemove` over focusable elements. ActionList has been updated to handle direct (key press) vs indirect (`mousemove`, DOM change, etc.) changes to active-descendant, and will use a distinct background color for the directly activated items.

src/ActionList/Item.tsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import styled from 'styled-components'
88
import {StyledHeader} from './Header'
99
import {StyledDivider} from './Divider'
1010
import {useColorSchemeVar, useTheme} from '../ThemeProvider'
11-
import {uniqueId} from '../utils/uniqueId'
11+
import {
12+
activeDescendantActivatedDirectly,
13+
activeDescendantActivatedIndirectly,
14+
isActiveDescendantAttribute
15+
} from '../behaviors/focusZone'
1216

1317
/**
1418
* These colors are not yet in our default theme. Need to remove this once they are added.
@@ -121,8 +125,6 @@ export interface ItemProps extends Omit<React.ComponentPropsWithoutRef<'div'>, '
121125
id?: number | string
122126
}
123127

124-
export const itemActiveDescendantClass = `${uniqueId()}active-descendant`
125-
126128
const getItemVariant = (variant = 'default', disabled?: boolean) => {
127129
if (disabled) {
128130
return {
@@ -176,10 +178,13 @@ const StyledItem = styled.div<
176178
display: flex;
177179
border-radius: ${get('radii.2')};
178180
color: ${({variant, item}) => getItemVariant(variant, item?.disabled).color};
181+
// 2 frames on a 60hz monitor
182+
transition: background 33.333ms linear;
179183
180184
@media (hover: hover) and (pointer: fine) {
181185
:hover {
182-
background: ${({hoverBackground}) => hoverBackground};
186+
// allow override in case another item in the list is active/focused
187+
background: var(--item-hover-bg-override, ${({hoverBackground}) => hoverBackground});
183188
cursor: ${({variant, item}) => getItemVariant(variant, item?.disabled).hoverCursor};
184189
}
185190
}
@@ -205,27 +210,41 @@ const StyledItem = styled.div<
205210
&:hover ${StyledItemContent}::before,
206211
// - below Hovered
207212
// '*' instead of '&' because '&' maps to separate class names depending on 'variant'
208-
:hover + * ${StyledItemContent}::before,
213+
:hover + * ${StyledItemContent}::before {
214+
// allow override in case another item in the list is active/focused
215+
border-color: var(--item-hover-divider-border-color-override, transparent) !important;
216+
}
217+
209218
// - above Focused
210219
&:focus ${StyledItemContent}::before,
211220
// - below Focused
212221
// '*' instead of '&' because '&' maps to separate class names depending on 'variant'
213222
:focus + * ${StyledItemContent}::before,
214223
// - above Active Descendent
215-
&.${itemActiveDescendantClass} ${StyledItemContent}::before,
224+
&[${isActiveDescendantAttribute}] ${StyledItemContent}::before,
216225
// - below Active Descendent
217-
.${itemActiveDescendantClass} + & ${StyledItemContent}::before {
226+
[${isActiveDescendantAttribute}] + & ${StyledItemContent}::before {
218227
// '!important' because all the ':not's above give higher specificity
219228
border-color: transparent !important;
220229
}
221230
222-
// Focused OR Active Descendant
223-
&:focus,
224-
&.${itemActiveDescendantClass} {
231+
// Active Descendant
232+
&[${isActiveDescendantAttribute}='${activeDescendantActivatedDirectly}'] {
233+
background: ${({focusBackground}) => focusBackground};
234+
}
235+
&[${isActiveDescendantAttribute}='${activeDescendantActivatedIndirectly}'] {
236+
background: ${({hoverBackground}) => hoverBackground};
237+
}
238+
239+
&:focus {
225240
background: ${({focusBackground}) => focusBackground};
226241
outline: none;
227242
}
228243
244+
&:active {
245+
background: ${({focusBackground}) => focusBackground};
246+
}
247+
229248
${sx}
230249
`
231250

src/ActionList/List.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {Divider} from './Divider'
66
import styled from 'styled-components'
77
import {get} from '../constants'
88
import {SystemCssProperties} from '@styled-system/css'
9+
import {hasActiveDescendantAttribute} from '../behaviors/focusZone'
910

1011
export type ItemInput = ItemProps | (Partial<ItemProps> & {renderItem: typeof Item})
1112

@@ -102,6 +103,11 @@ const StyledList = styled.div`
102103
* hardcoded '20px'
103104
*/
104105
line-height: 20px;
106+
107+
&[${hasActiveDescendantAttribute}], &:focus-within {
108+
--item-hover-bg-override: none;
109+
--item-hover-divider-border-color-override: ${get('colors.selectMenu.borderSecondary')};
110+
}
105111
`
106112

107113
/**
@@ -132,7 +138,7 @@ function useListVariant(variant: ListProps['variant'] = 'inset'): {
132138
/**
133139
* Lists `Item`s, either grouped or ungrouped, with a `Divider` between each `Group`.
134140
*/
135-
export function List(props: ListProps): JSX.Element {
141+
export const List = React.forwardRef<HTMLDivElement, ListProps>((props, forwardedRef): JSX.Element => {
136142
// Get `sx` prop values for `List` children matching the given `List` style variation.
137143
const {firstGroupStyle, lastGroupStyle, headerStyle, itemStyle} = useListVariant(props.variant)
138144

@@ -216,7 +222,7 @@ export function List(props: ListProps): JSX.Element {
216222
}
217223

218224
return (
219-
<StyledList {...props}>
225+
<StyledList {...props} ref={forwardedRef}>
220226
{groups.map(({header, ...groupProps}, index) => {
221227
const hasFilledHeader = header?.variant === 'filled'
222228
const shouldShowDivider = index > 0 && !hasFilledHeader
@@ -242,4 +248,6 @@ export function List(props: ListProps): JSX.Element {
242248
})}
243249
</StyledList>
244250
)
245-
}
251+
})
252+
253+
List.displayName = 'ActionList'

src/FilteredActionList/FilteredActionList.tsx

Lines changed: 10 additions & 22 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>()
@@ -93,38 +93,26 @@ export function FilteredActionList({
9393
containerRef: listContainerRef,
9494
focusOutBehavior: 'wrap',
9595
focusableElementFilter: element => {
96-
if (element instanceof HTMLInputElement) {
97-
// No active-descendant focus on checkboxes in list items
98-
return false
99-
}
100-
return true
96+
return !(element instanceof HTMLInputElement)
10197
},
10298
activeDescendantFocus: inputRef,
103-
onActiveDescendantChanged: (current, previous) => {
99+
onActiveDescendantChanged: (current, previous, directlyActivated) => {
104100
activeDescendantRef.current = current
105101

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-
}
102+
if (current && scrollContainerRef.current && directlyActivated) {
103+
scrollIntoViewingArea(current, scrollContainerRef.current)
116104
}
117105
}
118106
})
119107

120108
useEffect(() => {
121109
// if items changed, we want to instantly move active descendant into view
122-
if (activeDescendantRef.current && listContainerRef.current) {
123-
scrollIntoViewingArea(activeDescendantRef.current, listContainerRef.current, undefined, 'auto')
110+
if (activeDescendantRef.current && scrollContainerRef.current) {
111+
scrollIntoViewingArea(activeDescendantRef.current, scrollContainerRef.current, undefined, 'auto')
124112
}
125113
}, [items])
126114

127-
useScrollFlash(listContainerRef)
115+
useScrollFlash(scrollContainerRef)
128116

129117
return (
130118
<Flex flexDirection="column" overflow="hidden">
@@ -143,13 +131,13 @@ export function FilteredActionList({
143131
{...textInputProps}
144132
/>
145133
</StyledHeader>
146-
<Box ref={listContainerRef} overflow="auto">
134+
<Box ref={scrollContainerRef} overflow="auto">
147135
{loading ? (
148136
<Box width="100%" display="flex" flexDirection="row" justifyContent="center" pt={6} pb={7}>
149137
<Spinner />
150138
</Box>
151139
) : (
152-
<ActionList items={items} {...listProps} role="listbox" id={listId} />
140+
<ActionList ref={listContainerRef} items={items} {...listProps} role="listbox" id={listId} />
153141
)}
154142
</Box>
155143
</Flex>

src/__tests__/__snapshots__/ActionList.tsx.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ exports[`ActionList renders consistently 1`] = `
1111
line-height: 20px;
1212
}
1313
14+
.c0[data-has-active-descendant],
15+
.c0:focus-within {
16+
--item-hover-bg-override: none;
17+
--item-hover-divider-border-color-override: hsla(214,13%,93%,1);
18+
}
19+
1420
<div
1521
className="c0"
1622
>

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_10000"
7474
className="c0"
75-
id="__primer_id_10001"
75+
id="__primer_id_10000"
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_10000"
7373
className="c0"
74-
id="__primer_id_10001"
74+
id="__primer_id_10000"
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_10000"
7777
className="c0"
78-
id="__primer_id_10001"
78+
id="__primer_id_10000"
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_10000"
8989
className="c1"
90-
id="__primer_id_10001"
90+
id="__primer_id_10000"
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
})

0 commit comments

Comments
 (0)