Skip to content

Commit

Permalink
Merge pull request #263 from primer/tylerjdev/no-focus-if-hidden
Browse files Browse the repository at this point in the history
Prevent focusing hidden/disabled elements
  • Loading branch information
TylerJDev committed May 8, 2024
2 parents f0fbf65 + a54cafc commit 19d4d3d
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/eighty-steaks-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/behaviors': minor
---

Adjusts mutation observer to now track `hidden` and `disabled` attributes being applied or removed.
102 changes: 102 additions & 0 deletions src/__tests__/focus-zone.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -649,3 +649,105 @@ it('Shoud move to tabbable elements if onlyTabbable', async () => {

controller.abort()
})

it('Should ignore hidden elements after focus zone is enabled', async () => {
const user = userEvent.setup()
const {container, rerender} = render(
<div id="focusZone">
<button tabIndex={0}>Apple</button>
<button tabIndex={0}>Banana</button>
<button tabIndex={0}>Cantaloupe</button>
</div>,
)

const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
const [firstButton, , thirdButton] = focusZoneContainer.querySelectorAll('button')
const controller = focusZone(focusZoneContainer)

firstButton.focus()
expect(document.activeElement).toEqual(firstButton)

rerender(
<div id="focusZone">
<button tabIndex={0}>Apple</button>
<button tabIndex={0} hidden>
Banana
</button>
<button tabIndex={0}>Cantaloupe</button>
</div>,
)

await user.keyboard('{arrowdown}')
expect(document.activeElement).toEqual(thirdButton)

controller.abort()
})

it('Should respect unhidden elements after focus zone is enabled', async () => {
const user = userEvent.setup()
const {container, rerender} = render(
<div id="focusZone">
<button tabIndex={0}>Apple</button>
<button tabIndex={0} hidden>
Banana
</button>
<button tabIndex={0}>Cantaloupe</button>
</div>,
)

const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
const [firstButton, secondButton, thirdButton] = focusZoneContainer.querySelectorAll('button')
const controller = focusZone(focusZoneContainer)

firstButton.focus()
expect(document.activeElement).toEqual(firstButton)

await user.keyboard('{arrowdown}')
expect(document.activeElement).toEqual(thirdButton)

rerender(
<div id="focusZone">
<button tabIndex={0}>Apple</button>
<button tabIndex={0}>Banana</button>
<button tabIndex={0}>Cantaloupe</button>
</div>,
)

await user.keyboard('{arrowup}')
expect(document.activeElement).toEqual(secondButton)

controller.abort()
})

it('Should ignore disabled elements after focus zone is enabled', async () => {
const user = userEvent.setup()
const {container, rerender} = render(
<div id="focusZone">
<button tabIndex={0}>Apple</button>
<button tabIndex={0}>Banana</button>
<button tabIndex={0}>Cantaloupe</button>
</div>,
)

const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
const [firstButton, , thirdButton] = focusZoneContainer.querySelectorAll('button')
const controller = focusZone(focusZoneContainer)

firstButton.focus()
expect(document.activeElement).toEqual(firstButton)

rerender(
<div id="focusZone">
<button tabIndex={0}>Apple</button>
<button tabIndex={0} disabled>
Banana
</button>
<button tabIndex={0}>Cantaloupe</button>
</div>,
)

await user.keyboard('{arrowdown}')
expect(document.activeElement).toEqual(thirdButton)

controller.abort()
})
16 changes: 16 additions & 0 deletions src/focus-zone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,19 +527,35 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
endFocusManagement(...iterateFocusableElements(removedNode, iterateFocusableElementsOptions))
}
}
// If an element is hidden or disabled, remove it from the list of focusable elements
if (mutation.type === 'attributes' && mutation.oldValue === null) {
if (mutation.target instanceof HTMLElement) {
endFocusManagement(mutation.target)
}
}
}
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (addedNode instanceof HTMLElement) {
beginFocusManagement(...iterateFocusableElements(addedNode, iterateFocusableElementsOptions))
}
}

// Similarly, if an element is unhidden or "enabled", add it to the list of focusable elements
// If `mutation.oldValue` is not null, then we may assume that the element was previously hidden or disabled
if (mutation.type === 'attributes' && mutation.oldValue !== null) {
if (mutation.target instanceof HTMLElement) {
beginFocusManagement(mutation.target)
}
}
}
})

observer.observe(container, {
subtree: true,
childList: true,
attributeFilter: ['hidden', 'disabled'],
attributeOldValue: true,
})

const controller = new AbortController()
Expand Down

0 comments on commit 19d4d3d

Please sign in to comment.