Skip to content

Commit 6713e72

Browse files
authored
AvatarStack: Add keyboard support to AvatarStack (#5134)
* Add keyboard support for `AvatarStack` * Add to tests, function * Add changeset * Utilize focus styles for container * Update `getInteractiveNodes` * Change name * Change case * Rework `useEffect` * Add mutation observer, updates to `hasInteractiveNodes`
1 parent 33396ea commit 6713e72

File tree

6 files changed

+245
-11
lines changed

6 files changed

+245
-11
lines changed

.changeset/shy-seahorses-mix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
AvatarStack: Adds keyboard support to `AvatarStack`

packages/react/src/AvatarStack/AvatarStack.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {clsx} from 'clsx'
2-
import React from 'react'
2+
import React, {useEffect, useRef, useState} from 'react'
33
import styled from 'styled-components'
44
import {get} from '../constants'
55
import Box from '../Box'
@@ -12,6 +12,8 @@ import {isResponsiveValue} from '../hooks/useResponsiveValue'
1212
import {getBreakpointDeclarations} from '../utils/getBreakpointDeclarations'
1313
import {defaultSxProp} from '../utils/defaultSxProp'
1414
import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
15+
import {hasInteractiveNodes} from '../internal/utils/hasInteractiveNodes'
16+
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
1517

1618
type StyledAvatarStackWrapperProps = {
1719
count?: number
@@ -30,6 +32,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
3032
.pc-AvatarStackBody {
3133
display: flex;
3234
position: absolute;
35+
36+
${getGlobalFocusStyles('1px')}
3337
}
3438
3539
.pc-AvatarItem {
@@ -130,7 +134,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
130134
.pc-AvatarStackBody {
131135
flex-direction: row-reverse;
132136
133-
&:not(.pc-AvatarStack--disableExpand):hover {
137+
&:not(.pc-AvatarStack--disableExpand):hover,
138+
&:not(.pc-AvatarStack--disableExpand):focus-within {
134139
.pc-AvatarItem {
135140
margin-right: ${get('space.1')}!important;
136141
margin-left: 0 !important;
@@ -143,7 +148,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
143148
}
144149
}
145150
146-
.pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover {
151+
.pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover,
152+
.pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within {
147153
width: auto;
148154
149155
.pc-AvatarItem {
@@ -157,6 +163,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
157163
visibility 0.2s ease-in-out,
158164
box-shadow 0.1s ease-in-out;
159165
166+
${getGlobalFocusStyles('1px')}
167+
160168
&:first-child {
161169
margin-left: 0;
162170
}
@@ -195,6 +203,9 @@ const AvatarStack = ({
195203
className,
196204
sx: sxProp = defaultSxProp,
197205
}: AvatarStackProps) => {
206+
const [hasInteractiveChildren, setHasInteractiveChildren] = useState<boolean | undefined>(false)
207+
const stackContainer = useRef<HTMLDivElement>(null)
208+
198209
const count = React.Children.count(children)
199210
const wrapperClassNames = clsx(
200211
{
@@ -249,6 +260,25 @@ const AvatarStack = ({
249260
)
250261
}
251262

263+
useEffect(() => {
264+
if (stackContainer.current) {
265+
const interactiveChildren = () => {
266+
setHasInteractiveChildren(hasInteractiveNodes(stackContainer.current))
267+
}
268+
269+
const observer = new MutationObserver(interactiveChildren)
270+
271+
observer.observe(stackContainer.current, {childList: true})
272+
273+
// Call on initial render, then call it again only if there's a mutation
274+
interactiveChildren()
275+
276+
return () => {
277+
observer.disconnect()
278+
}
279+
}
280+
}, [])
281+
252282
const getResponsiveAvatarSizeStyles = () => {
253283
// if there is no size set on the AvatarStack, use the `size` props of the Avatar children to set the `--avatar-stack-size` CSS variable
254284
if (!size) {
@@ -279,7 +309,13 @@ const AvatarStack = ({
279309

280310
return (
281311
<AvatarStackWrapper count={count} className={wrapperClassNames} sx={avatarStackSx}>
282-
<Box className={bodyClassNames}> {transformChildren(children)}</Box>
312+
<Box
313+
className={bodyClassNames}
314+
tabIndex={!hasInteractiveChildren && !disableExpand ? 0 : undefined}
315+
ref={stackContainer}
316+
>
317+
{transformChildren(children)}
318+
</Box>
283319
</AvatarStackWrapper>
284320
)
285321
}

packages/react/src/__tests__/AvatarStack.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,28 @@ describe('Avatar', () => {
6969
it('respects alignRight props', () => {
7070
expect(render(rightAvatarComp)).toMatchSnapshot()
7171
})
72+
73+
it('should have a tabindex of 0 if there are no interactive children', () => {
74+
const {container} = HTMLRender(avatarComp)
75+
expect(container.querySelector('[tabindex="0"]')).toBeInTheDocument()
76+
})
77+
78+
it('should not have a tabindex if there are interactive children', () => {
79+
const {container} = HTMLRender(
80+
<AvatarStack>
81+
<button type="button">Click me</button>
82+
</AvatarStack>,
83+
)
84+
expect(container.querySelector('[tabindex="0"]')).not.toBeInTheDocument()
85+
})
86+
87+
it('should not have a tabindex if disableExpand is true', () => {
88+
const {container} = HTMLRender(
89+
<AvatarStack disableExpand>
90+
<img src="https://avatars.githubusercontent.com/primer" alt="" />
91+
<img src="https://avatars.githubusercontent.com/github" alt="" />
92+
</AvatarStack>,
93+
)
94+
expect(container.querySelector('[tabindex="0"]')).not.toBeInTheDocument()
95+
})
7296
})

packages/react/src/__tests__/__snapshots__/AvatarStack.test.tsx.snap

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ exports[`Avatar respects alignRight props 1`] = `
2323
position: absolute;
2424
}
2525
26+
.c0 .pc-AvatarStackBody:focus:not(:disabled) {
27+
box-shadow: none;
28+
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
29+
outline-offset: 1px;
30+
}
31+
32+
.c0 .pc-AvatarStackBody:focus:not(:disabled):not(:focus-visible) {
33+
outline: solid 1px transparent;
34+
}
35+
36+
.c0 .pc-AvatarStackBody:focus-visible:not(:disabled) {
37+
box-shadow: none;
38+
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
39+
outline-offset: 1px;
40+
}
41+
2642
.c0 .pc-AvatarItem {
2743
--avatar-size: var(--avatar-stack-size);
2844
-webkit-flex-shrink: 0;
@@ -107,32 +123,57 @@ exports[`Avatar respects alignRight props 1`] = `
107123
flex-direction: row-reverse;
108124
}
109125
110-
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem {
126+
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem,
127+
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem {
111128
margin-right: 4px!important;
112129
margin-left: 0 !important;
113130
}
114131
115-
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child {
132+
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child,
133+
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:first-child {
116134
margin-right: 0 !important;
117135
}
118136
119-
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover {
137+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover,
138+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within {
120139
width: auto;
121140
}
122141
123-
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem {
142+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem,
143+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem {
124144
margin-left: 4px;
125145
opacity: 100%;
126146
visibility: visible;
127147
-webkit-transition: margin 0.2s ease-in-out,opacity 0.2s ease-in-out,visibility 0.2s ease-in-out,box-shadow 0.1s ease-in-out;
128148
transition: margin 0.2s ease-in-out,opacity 0.2s ease-in-out,visibility 0.2s ease-in-out,box-shadow 0.1s ease-in-out;
129149
}
130150
131-
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem box-shadow:inset 0 0 0 4px function (props) {
151+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem box-shadow:inset 0 0 0 4px function (props),
152+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem box-shadow:inset 0 0 0 4px function (props) {
132153
return: (0,_core.get)(props.theme,path,fallback);
133154
}
134155
135-
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child {
156+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:focus:not(:disabled),
157+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:focus:not(:disabled) {
158+
box-shadow: none;
159+
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
160+
outline-offset: 1px;
161+
}
162+
163+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:focus:not(:disabled):not(:focus-visible),
164+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:focus:not(:disabled):not(:focus-visible) {
165+
outline: solid 1px transparent;
166+
}
167+
168+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:focus-visible:not(:disabled),
169+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:focus-visible:not(:disabled) {
170+
box-shadow: none;
171+
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
172+
outline-offset: 1px;
173+
}
174+
175+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child,
176+
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:first-child {
136177
margin-left: 0;
137178
}
138179
@@ -145,8 +186,8 @@ exports[`Avatar respects alignRight props 1`] = `
145186
>
146187
<div
147188
className="pc-AvatarStackBody"
189+
tabIndex={0}
148190
>
149-
150191
<img
151192
alt=""
152193
className="pc-AvatarItem"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {hasInteractiveNodes} from '../hasInteractiveNodes'
2+
3+
describe('hasInteractiveNodes', () => {
4+
test('if there are no interactive nodes', () => {
5+
const node = document.createElement('div')
6+
expect(hasInteractiveNodes(node)).toBe(false)
7+
})
8+
9+
test('if there are interactive nodes', () => {
10+
const node = document.createElement('div')
11+
const button = document.createElement('button')
12+
node.appendChild(button)
13+
14+
expect(hasInteractiveNodes(node)).toBe(true)
15+
})
16+
17+
test('if the node itself is interactive', () => {
18+
const node = document.createElement('button')
19+
20+
expect(hasInteractiveNodes(node)).toBe(false)
21+
})
22+
23+
test('if there are nested interactive nodes', () => {
24+
const node = document.createElement('div')
25+
const wrapper = document.createElement('div')
26+
const button = document.createElement('button')
27+
const span = document.createElement('span')
28+
wrapper.appendChild(button)
29+
button.appendChild(span)
30+
node.appendChild(wrapper)
31+
32+
expect(hasInteractiveNodes(node)).toBe(true)
33+
})
34+
35+
test('if the node is disabled', () => {
36+
const node = document.createElement('button')
37+
node.disabled = true
38+
39+
expect(hasInteractiveNodes(node)).toBe(false)
40+
})
41+
42+
test('if the child node is disabled', () => {
43+
const node = document.createElement('div')
44+
const button = document.createElement('button')
45+
button.disabled = true
46+
node.appendChild(button)
47+
48+
expect(hasInteractiveNodes(node)).toBe(false)
49+
})
50+
51+
test('if child node has tabindex', () => {
52+
const node = document.createElement('div')
53+
const span = document.createElement('span')
54+
span.setAttribute('tabindex', '0')
55+
node.appendChild(span)
56+
57+
expect(hasInteractiveNodes(node)).toBe(true)
58+
})
59+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
const nonValidSelectors = {
2+
disabled: '[disabled]',
3+
hidden: '[hidden]',
4+
inert: '[inert]',
5+
negativeTabIndex: '[tabindex="-1"]',
6+
}
7+
8+
const interactiveElementsSelectors = [
9+
`a[href]`,
10+
`button`,
11+
'summary',
12+
'select',
13+
'input:not([type=hidden])',
14+
'textarea',
15+
'[tabindex="0"]',
16+
`audio[controls]`,
17+
`video[controls]`,
18+
`[contenteditable]`,
19+
]
20+
21+
const interactiveElements = interactiveElementsSelectors.map(
22+
selector => `${selector}:not(${Object.values(nonValidSelectors).join('):not(')})`,
23+
)
24+
25+
/**
26+
* Finds interactive nodes within the passed node.
27+
* If the node itself is interactive, or children within are, it will return true.
28+
*
29+
* @param node - The HTML element to search for interactive nodes in.
30+
* @param ignoreSelectors - A string of selectors to ignore when searching for interactive nodes. This is useful for
31+
* ignoring nodes that are conditionally interactive based on the return value of the function.
32+
* @returns {boolean | undefined}
33+
*/
34+
export function hasInteractiveNodes(node: HTMLElement | null, ignoreNodes?: HTMLElement[]) {
35+
if (!node || isNonValidInteractiveNode(node)) return false
36+
37+
// We only need to confirm if at least one interactive node exists.
38+
// If one does exist, we can abort early.
39+
40+
const nodesToIgnore = ignoreNodes ? [node, ...ignoreNodes] : [node]
41+
const interactiveNodes = findInteractiveChildNodes(node, nodesToIgnore)
42+
43+
return Boolean(interactiveNodes)
44+
}
45+
46+
function isNonValidInteractiveNode(node: HTMLElement) {
47+
const nodeStyle = getComputedStyle(node)
48+
const isNonInteractive = node.matches('[disabled], [hidden], [inert]')
49+
const isHiddenVisually = nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden'
50+
51+
return isNonInteractive || isHiddenVisually
52+
}
53+
54+
function findInteractiveChildNodes(node: HTMLElement | null, ignoreNodes: HTMLElement[]) {
55+
if (!node) return
56+
57+
const ignoreSelector = ignoreNodes.find(elem => elem === node)
58+
const isNotValidNode = isNonValidInteractiveNode(node)
59+
60+
if (node.matches(interactiveElements.join(', ')) && !ignoreSelector && !isNotValidNode) {
61+
return node
62+
}
63+
64+
for (const child of node.children) {
65+
const interactiveNode = findInteractiveChildNodes(child as HTMLElement, ignoreNodes)
66+
67+
if (interactiveNode) return true
68+
}
69+
}

0 commit comments

Comments
 (0)