Skip to content

Commit

Permalink
AvatarStack: Add keyboard support to AvatarStack (#5134)
Browse files Browse the repository at this point in the history
* 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`
  • Loading branch information
TylerJDev authored Oct 25, 2024
1 parent 33396ea commit 6713e72
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 11 deletions.
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
}
}

0 comments on commit 6713e72

Please sign in to comment.