diff --git a/.changeset/shy-seahorses-mix.md b/.changeset/shy-seahorses-mix.md new file mode 100644 index 00000000000..1819269fecf --- /dev/null +++ b/.changeset/shy-seahorses-mix.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +AvatarStack: Adds keyboard support to `AvatarStack` diff --git a/packages/react/src/AvatarStack/AvatarStack.tsx b/packages/react/src/AvatarStack/AvatarStack.tsx index f07615f7214..8430fc199b5 100644 --- a/packages/react/src/AvatarStack/AvatarStack.tsx +++ b/packages/react/src/AvatarStack/AvatarStack.tsx @@ -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' @@ -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 @@ -30,6 +32,8 @@ const AvatarStackWrapper = styled.span` .pc-AvatarStackBody { display: flex; position: absolute; + + ${getGlobalFocusStyles('1px')} } .pc-AvatarItem { @@ -130,7 +134,8 @@ const AvatarStackWrapper = styled.span` .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; @@ -143,7 +148,8 @@ const AvatarStackWrapper = styled.span` } } - .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 { @@ -157,6 +163,8 @@ const AvatarStackWrapper = styled.span` visibility 0.2s ease-in-out, box-shadow 0.1s ease-in-out; + ${getGlobalFocusStyles('1px')} + &:first-child { margin-left: 0; } @@ -195,6 +203,9 @@ const AvatarStack = ({ className, sx: sxProp = defaultSxProp, }: AvatarStackProps) => { + const [hasInteractiveChildren, setHasInteractiveChildren] = useState(false) + const stackContainer = useRef(null) + const count = React.Children.count(children) const wrapperClassNames = clsx( { @@ -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) { @@ -279,7 +309,13 @@ const AvatarStack = ({ return ( - {transformChildren(children)} + + {transformChildren(children)} + ) } diff --git a/packages/react/src/__tests__/AvatarStack.test.tsx b/packages/react/src/__tests__/AvatarStack.test.tsx index 2807a9b76aa..9b5bfd27a4b 100644 --- a/packages/react/src/__tests__/AvatarStack.test.tsx +++ b/packages/react/src/__tests__/AvatarStack.test.tsx @@ -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( + + + , + ) + expect(container.querySelector('[tabindex="0"]')).not.toBeInTheDocument() + }) + + it('should not have a tabindex if disableExpand is true', () => { + const {container} = HTMLRender( + + + + , + ) + expect(container.querySelector('[tabindex="0"]')).not.toBeInTheDocument() + }) }) diff --git a/packages/react/src/__tests__/__snapshots__/AvatarStack.test.tsx.snap b/packages/react/src/__tests__/__snapshots__/AvatarStack.test.tsx.snap index f3c1df84433..cc28e33c219 100644 --- a/packages/react/src/__tests__/__snapshots__/AvatarStack.test.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/AvatarStack.test.tsx.snap @@ -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; @@ -107,20 +123,24 @@ 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; @@ -128,11 +148,32 @@ exports[`Avatar respects alignRight props 1`] = ` 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; } @@ -145,8 +186,8 @@ exports[`Avatar respects alignRight props 1`] = ` >
- { + 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) + }) +}) diff --git a/packages/react/src/internal/utils/hasInteractiveNodes.ts b/packages/react/src/internal/utils/hasInteractiveNodes.ts new file mode 100644 index 00000000000..b74c52dd9fe --- /dev/null +++ b/packages/react/src/internal/utils/hasInteractiveNodes.ts @@ -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 + } +}