-
Notifications
You must be signed in to change notification settings - Fork 538
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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`
- Loading branch information
Showing
6 changed files
with
245 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@primer/react': minor | ||
--- | ||
|
||
AvatarStack: Adds keyboard support to `AvatarStack` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
packages/react/src/internal/utils/__tests__/hasInteractiveNodes.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |