Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AvatarStack: Add keyboard support to AvatarStack #5134

Merged
merged 12 commits into from
Oct 25, 2024
5 changes: 5 additions & 0 deletions .changeset/shy-seahorses-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

AvatarStack: Adds keyboard support to `AvatarStack`
44 changes: 40 additions & 4 deletions packages/react/src/AvatarStack/AvatarStack.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {clsx} from 'clsx'
import React from 'react'
import React, {useEffect, useRef, useState} from 'react'
import styled from 'styled-components'
import {get} from '../constants'
import Box from '../Box'
Expand All @@ -12,6 +12,8 @@ import {isResponsiveValue} from '../hooks/useResponsiveValue'
import {getBreakpointDeclarations} from '../utils/getBreakpointDeclarations'
import {defaultSxProp} from '../utils/defaultSxProp'
import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
import {hasInteractiveNodes} from '../internal/utils/hasInteractiveNodes'
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'

type StyledAvatarStackWrapperProps = {
count?: number
Expand All @@ -30,6 +32,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
.pc-AvatarStackBody {
display: flex;
position: absolute;

${getGlobalFocusStyles('1px')}
}

.pc-AvatarItem {
Expand Down Expand Up @@ -130,7 +134,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
.pc-AvatarStackBody {
flex-direction: row-reverse;

&:not(.pc-AvatarStack--disableExpand):hover {
&:not(.pc-AvatarStack--disableExpand):hover,
&:not(.pc-AvatarStack--disableExpand):focus-within {
.pc-AvatarItem {
margin-right: ${get('space.1')}!important;
margin-left: 0 !important;
Expand All @@ -143,7 +148,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
}
}

.pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover {
.pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover,
.pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within {
width: auto;

.pc-AvatarItem {
Expand All @@ -157,6 +163,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
visibility 0.2s ease-in-out,
box-shadow 0.1s ease-in-out;

${getGlobalFocusStyles('1px')}

&:first-child {
margin-left: 0;
}
Expand Down Expand Up @@ -195,6 +203,9 @@ const AvatarStack = ({
className,
sx: sxProp = defaultSxProp,
}: AvatarStackProps) => {
const [hasInteractiveChildren, setHasInteractiveChildren] = useState<boolean | undefined>(false)
const stackContainer = useRef<HTMLDivElement>(null)

const count = React.Children.count(children)
const wrapperClassNames = clsx(
{
Expand Down Expand Up @@ -249,6 +260,25 @@ const AvatarStack = ({
)
}

useEffect(() => {
if (stackContainer.current) {
const interactiveChildren = () => {
setHasInteractiveChildren(hasInteractiveNodes(stackContainer.current))
}

const observer = new MutationObserver(interactiveChildren)

observer.observe(stackContainer.current, {childList: true})

// Call on initial render, then call it again only if there's a mutation
interactiveChildren()

return () => {
observer.disconnect()
}
}
}, [])

const getResponsiveAvatarSizeStyles = () => {
// 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
if (!size) {
Expand Down Expand Up @@ -279,7 +309,13 @@ const AvatarStack = ({

return (
<AvatarStackWrapper count={count} className={wrapperClassNames} sx={avatarStackSx}>
<Box className={bodyClassNames}> {transformChildren(children)}</Box>
<Box
className={bodyClassNames}
tabIndex={!hasInteractiveChildren && !disableExpand ? 0 : undefined}
ref={stackContainer}
>
{transformChildren(children)}
</Box>
</AvatarStackWrapper>
)
}
Expand Down
24 changes: 24 additions & 0 deletions packages/react/src/__tests__/AvatarStack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,28 @@ describe('Avatar', () => {
it('respects alignRight props', () => {
expect(render(rightAvatarComp)).toMatchSnapshot()
})

it('should have a tabindex of 0 if there are no interactive children', () => {
const {container} = HTMLRender(avatarComp)
expect(container.querySelector('[tabindex="0"]')).toBeInTheDocument()
})

it('should not have a tabindex if there are interactive children', () => {
const {container} = HTMLRender(
<AvatarStack>
<button type="button">Click me</button>
</AvatarStack>,
)
expect(container.querySelector('[tabindex="0"]')).not.toBeInTheDocument()
})

it('should not have a tabindex if disableExpand is true', () => {
const {container} = HTMLRender(
<AvatarStack disableExpand>
<img src="https://avatars.githubusercontent.com/primer" alt="" />
<img src="https://avatars.githubusercontent.com/github" alt="" />
</AvatarStack>,
)
expect(container.querySelector('[tabindex="0"]')).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ exports[`Avatar respects alignRight props 1`] = `
position: absolute;
}

.c0 .pc-AvatarStackBody:focus:not(:disabled) {
box-shadow: none;
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
outline-offset: 1px;
}

.c0 .pc-AvatarStackBody:focus:not(:disabled):not(:focus-visible) {
outline: solid 1px transparent;
}

.c0 .pc-AvatarStackBody:focus-visible:not(:disabled) {
box-shadow: none;
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
outline-offset: 1px;
}

.c0 .pc-AvatarItem {
--avatar-size: var(--avatar-stack-size);
-webkit-flex-shrink: 0;
Expand Down Expand Up @@ -107,32 +123,57 @@ exports[`Avatar respects alignRight props 1`] = `
flex-direction: row-reverse;
}

.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem {
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem,
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem {
margin-right: 4px!important;
margin-left: 0 !important;
}

.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child {
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child,
.c0.pc-AvatarStack--right .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:first-child {
margin-right: 0 !important;
}

.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover {
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover,
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within {
width: auto;
}

.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem {
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem,
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem {
margin-left: 4px;
opacity: 100%;
visibility: visible;
-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;
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;
}

.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem box-shadow:inset 0 0 0 4px function (props) {
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem box-shadow:inset 0 0 0 4px function (props),
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem box-shadow:inset 0 0 0 4px function (props) {
return: (0,_core.get)(props.theme,path,fallback);
}

.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child {
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:focus:not(:disabled),
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:focus:not(:disabled) {
box-shadow: none;
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
outline-offset: 1px;
}

.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:focus:not(:disabled):not(:focus-visible),
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:focus:not(:disabled):not(:focus-visible) {
outline: solid 1px transparent;
}

.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:focus-visible:not(:disabled),
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:focus-visible:not(:disabled) {
box-shadow: none;
outline: 2px solid var(--fgColor-accent,var(--color-accent-fg,#0969da));
outline-offset: 1px;
}

.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover .pc-AvatarItem:first-child,
.c0 .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within .pc-AvatarItem:first-child {
margin-left: 0;
}

Expand All @@ -145,8 +186,8 @@ exports[`Avatar respects alignRight props 1`] = `
>
<div
className="pc-AvatarStackBody"
tabIndex={0}
>

<img
alt=""
className="pc-AvatarItem"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {hasInteractiveNodes} from '../hasInteractiveNodes'

describe('hasInteractiveNodes', () => {
test('if there are no interactive nodes', () => {
const node = document.createElement('div')
expect(hasInteractiveNodes(node)).toBe(false)
})

test('if there are interactive nodes', () => {
const node = document.createElement('div')
const button = document.createElement('button')
node.appendChild(button)

expect(hasInteractiveNodes(node)).toBe(true)
})

test('if the node itself is interactive', () => {
const node = document.createElement('button')

expect(hasInteractiveNodes(node)).toBe(false)
})

test('if there are nested interactive nodes', () => {
const node = document.createElement('div')
const wrapper = document.createElement('div')
const button = document.createElement('button')
const span = document.createElement('span')
wrapper.appendChild(button)
button.appendChild(span)
node.appendChild(wrapper)

expect(hasInteractiveNodes(node)).toBe(true)
})

test('if the node is disabled', () => {
const node = document.createElement('button')
node.disabled = true

expect(hasInteractiveNodes(node)).toBe(false)
})

test('if the child node is disabled', () => {
const node = document.createElement('div')
const button = document.createElement('button')
button.disabled = true
node.appendChild(button)

expect(hasInteractiveNodes(node)).toBe(false)
})

test('if child node has tabindex', () => {
const node = document.createElement('div')
const span = document.createElement('span')
span.setAttribute('tabindex', '0')
node.appendChild(span)

expect(hasInteractiveNodes(node)).toBe(true)
})
})
69 changes: 69 additions & 0 deletions packages/react/src/internal/utils/hasInteractiveNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const nonValidSelectors = {
disabled: '[disabled]',
hidden: '[hidden]',
inert: '[inert]',
negativeTabIndex: '[tabindex="-1"]',
}

const interactiveElementsSelectors = [
`a[href]`,
`button`,
'summary',
'select',
'input:not([type=hidden])',
'textarea',
'[tabindex="0"]',
`audio[controls]`,
`video[controls]`,
`[contenteditable]`,
]

const interactiveElements = interactiveElementsSelectors.map(
selector => `${selector}:not(${Object.values(nonValidSelectors).join('):not(')})`,
)

/**
* Finds interactive nodes within the passed node.
* If the node itself is interactive, or children within are, it will return true.
*
* @param node - The HTML element to search for interactive nodes in.
* @param ignoreSelectors - A string of selectors to ignore when searching for interactive nodes. This is useful for
* ignoring nodes that are conditionally interactive based on the return value of the function.
* @returns {boolean | undefined}
*/
export function hasInteractiveNodes(node: HTMLElement | null, ignoreNodes?: HTMLElement[]) {
if (!node || isNonValidInteractiveNode(node)) return false

// We only need to confirm if at least one interactive node exists.
// If one does exist, we can abort early.

const nodesToIgnore = ignoreNodes ? [node, ...ignoreNodes] : [node]
const interactiveNodes = findInteractiveChildNodes(node, nodesToIgnore)

return Boolean(interactiveNodes)
}

function isNonValidInteractiveNode(node: HTMLElement) {
const nodeStyle = getComputedStyle(node)
const isNonInteractive = node.matches('[disabled], [hidden], [inert]')
const isHiddenVisually = nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden'

return isNonInteractive || isHiddenVisually
}

function findInteractiveChildNodes(node: HTMLElement | null, ignoreNodes: HTMLElement[]) {
if (!node) return

const ignoreSelector = ignoreNodes.find(elem => elem === node)
const isNotValidNode = isNonValidInteractiveNode(node)

if (node.matches(interactiveElements.join(', ')) && !ignoreSelector && !isNotValidNode) {
return node
}

for (const child of node.children) {
const interactiveNode = findInteractiveChildNodes(child as HTMLElement, ignoreNodes)

if (interactiveNode) return true
}
}
Loading