-
Notifications
You must be signed in to change notification settings - Fork 537
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
Changes from 5 commits
5a974b9
5dccb22
f26601b
dee9a1c
6090588
8c1fc8d
6d1f8e4
cb4fd73
a3417bb
298262e
f0df4af
321553a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@primer/react': minor | ||
--- | ||
|
||
AvatarStack: Adds keyboard support to `AvatarStack` |
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' | ||||||||||||||||||||||||||||||
|
@@ -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 {getInteractiveNodes} from '../internal/utils/getInteractiveNodes' | ||||||||||||||||||||||||||||||
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles' | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
type StyledAvatarStackWrapperProps = { | ||||||||||||||||||||||||||||||
count?: number | ||||||||||||||||||||||||||||||
|
@@ -30,6 +32,8 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>` | |||||||||||||||||||||||||||||
.pc-AvatarStackBody { | ||||||||||||||||||||||||||||||
display: flex; | ||||||||||||||||||||||||||||||
position: absolute; | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
${getGlobalFocusStyles('1px')} | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
.pc-AvatarItem { | ||||||||||||||||||||||||||||||
|
@@ -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; | ||||||||||||||||||||||||||||||
|
@@ -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 { | ||||||||||||||||||||||||||||||
|
@@ -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; | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
@@ -195,6 +203,9 @@ const AvatarStack = ({ | |||||||||||||||||||||||||||||
className, | ||||||||||||||||||||||||||||||
sx: sxProp = defaultSxProp, | ||||||||||||||||||||||||||||||
}: AvatarStackProps) => { | ||||||||||||||||||||||||||||||
const [hasInteractiveChildren, setHasInteractiveChildren] = useState<boolean | undefined>(false) | ||||||||||||||||||||||||||||||
const AvatarStackContainer = useRef(null) | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
const count = React.Children.count(children) | ||||||||||||||||||||||||||||||
const wrapperClassNames = clsx( | ||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||
|
@@ -249,6 +260,11 @@ const AvatarStack = ({ | |||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
useEffect(() => { | ||||||||||||||||||||||||||||||
const hasInteractive = getInteractiveNodes(AvatarStackContainer.current, '[tabindex]') | ||||||||||||||||||||||||||||||
setHasInteractiveChildren(hasInteractive) | ||||||||||||||||||||||||||||||
}, [children]) | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would this be equivalent to: useEffect(() => {
// ...
}) I wasn't sure if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could! My assumption is that we'd want to utilize the mutation observer inside of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you could use it in there if the function was updated to have a callback/subscriber for when it changed or could use the mutation observer directly and call
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see, this is great! Thank you for the example too. I'll rework this a bit. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After implementing this, I realize that the need for checking the children for changes is probably very slim 😅 I removed the |
||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
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) { | ||||||||||||||||||||||||||||||
|
@@ -278,8 +294,10 @@ const AvatarStack = ({ | |||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
return ( | ||||||||||||||||||||||||||||||
<AvatarStackWrapper count={count} className={wrapperClassNames} sx={avatarStackSx}> | ||||||||||||||||||||||||||||||
<Box className={bodyClassNames}> {transformChildren(children)}</Box> | ||||||||||||||||||||||||||||||
<AvatarStackWrapper count={count} className={wrapperClassNames} sx={avatarStackSx} ref={AvatarStackContainer}> | ||||||||||||||||||||||||||||||
<Box className={bodyClassNames} tabIndex={!hasInteractiveChildren && !disableExpand ? 0 : undefined}> | ||||||||||||||||||||||||||||||
{transformChildren(children)} | ||||||||||||||||||||||||||||||
</Box> | ||||||||||||||||||||||||||||||
</AvatarStackWrapper> | ||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import {getInteractiveNodes} from '../getInteractiveNodes' | ||
|
||
describe('getInteractiveNodes', () => { | ||
test('if there are no interactive nodes', () => { | ||
const node = document.createElement('div') | ||
expect(getInteractiveNodes(node)).toBeUndefined() | ||
}) | ||
|
||
test('if there are interactive nodes', () => { | ||
const node = document.createElement('div') | ||
const button = document.createElement('button') | ||
node.appendChild(button) | ||
|
||
expect(getInteractiveNodes(node)).toBe(true) | ||
}) | ||
|
||
test('if the node itself is interactive', () => { | ||
const node = document.createElement('button') | ||
|
||
expect(getInteractiveNodes(node)).toBe(true) | ||
}) | ||
|
||
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(getInteractiveNodes(node)).toBe(true) | ||
}) | ||
|
||
test('if the node is disabled', () => { | ||
const node = document.createElement('button') | ||
node.disabled = true | ||
|
||
expect(getInteractiveNodes(node)).toBeUndefined() | ||
}) | ||
|
||
test('if the child node is disabled', () => { | ||
const node = document.createElement('div') | ||
const button = document.createElement('button') | ||
button.disabled = true | ||
node.appendChild(button) | ||
|
||
expect(getInteractiveNodes(node)).toBeUndefined() | ||
}) | ||
|
||
test('if child node has tabindex', () => { | ||
const node = document.createElement('div') | ||
const span = document.createElement('span') | ||
span.setAttribute('tabindex', '0') | ||
node.appendChild(span) | ||
|
||
expect(getInteractiveNodes(node)).toBe(true) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,23 @@ | ||||||
const interactiveElements = [ | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One package we could use for this: https://www.npmjs.com/package/focusable-selectors (either as a dependency or inline with accreditation) that has a pretty robust list 👀 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Definitely! Added some more selectors that could be handy. |
||||||
'a[href]', | ||||||
'button:not([disabled])', | ||||||
'summary', | ||||||
'select', | ||||||
'input:not([type=hidden])', | ||||||
'textarea', | ||||||
'[tabindex="0"]', | ||||||
] | ||||||
|
||||||
export function getInteractiveNodes(node: HTMLElement | null, ignoreSelectors?: string) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One style suggestion, since we're returning a
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good callout. I changed the names of the function + file! |
||||||
if (!node) return | ||||||
|
||||||
let interactiveNodes = Array.from(node.querySelectorAll(interactiveElements.join(', '))) | ||||||
|
||||||
if (ignoreSelectors) { | ||||||
interactiveNodes = interactiveNodes.filter(node => !node.matches(ignoreSelectors)) | ||||||
} | ||||||
|
||||||
if (interactiveNodes.length || node.matches(interactiveElements.join(', '))) { | ||||||
return true | ||||||
} | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style nit (and definitely non blocking lol) we could use lowercase for
useRef
here since we typically only use pascal case for component names or modulesThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Didn't realize I was using pascal case 🤦 Should be updated now!