Skip to content

Commit

Permalink
Merge pull request #429 from primer/tylerjdev/add-observer-to-focus-trap
Browse files Browse the repository at this point in the history
Add observer to focus-trap
  • Loading branch information
TylerJDev authored Aug 23, 2024
2 parents b1b531d + 07e1664 commit e7bb7b2
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/curly-lions-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/behaviors': patch
---

Adds mutation observer to `focus-trap` to ensure sentinel elements are always in the correct position
88 changes: 88 additions & 0 deletions src/__tests__/focus-trap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,91 @@ it('Should handle dynamic content', async () => {

controller?.abort()
})

it('should keep the sentinel elements at the start/end of the inner container', async () => {
const user = userEvent.setup()
const {container} = render(
<div>
<div id="trapContainer">
<button tabIndex={0}>Apple</button>
<button tabIndex={0}>Banana</button>
<button tabIndex={0}>Cantaloupe</button>
</div>
<button id="durian" tabIndex={0}>
Durian
</button>
</div>,
)

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

secondButton.focus()
await user.tab()
await user.tab()
expect(document.activeElement).toEqual(firstButton)

trapContainer.insertAdjacentHTML('afterbegin', '<button id="first" tabindex="0">New first button</button>')
const newFirstButton = trapContainer.querySelector('#first')

const sentinelStart = trapContainer.querySelector('.sentinel')

await user.tab({shift: true})
expect(trapContainer.firstElementChild).toEqual(sentinelStart)
expect(document.activeElement).toEqual(newFirstButton)

trapContainer.insertAdjacentHTML('beforeend', '<button id="last" tabindex="0">New last button</button>')
const newLastButton = trapContainer.querySelector('#last')

const sentinelEnd = trapContainer.querySelector('.sentinel')

await user.tab({shift: true})
expect(trapContainer.lastElementChild).toEqual(sentinelEnd)
expect(document.activeElement).toEqual(newLastButton)

controller?.abort()
})

it('should remove the mutation observer when the focus trap is released', async () => {
const user = userEvent.setup()
const {container} = render(
<div>
<div id="trapContainer">
<button tabIndex={0}>Apple</button>
<button tabIndex={0}>Banana</button>
<button tabIndex={0}>Cantaloupe</button>
</div>
<button id="durian" tabIndex={0}>
Durian
</button>
</div>,
)

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

secondButton.focus()
await user.tab()
await user.tab()
expect(document.activeElement).toEqual(firstButton)

trapContainer.insertAdjacentHTML('afterbegin', '<button id="first" tabindex="0">New first button</button>')
const newFirstButton = trapContainer.querySelector('#first')

const sentinelStart = trapContainer.querySelector('.sentinel')

await user.tab({shift: true})
expect(trapContainer.firstElementChild).toEqual(sentinelStart)
expect(document.activeElement).toEqual(newFirstButton)

controller?.abort()

trapContainer.insertAdjacentHTML('beforeend', '<button id="last" tabindex="0">New last button</button>')
const newLastButton = trapContainer.querySelector('#last')

await user.tab({shift: true})
expect(document.activeElement).not.toEqual(newLastButton)
expect(trapContainer.lastElementChild).toEqual(newLastButton)
})
37 changes: 37 additions & 0 deletions src/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,40 @@ function followSignal(signal: AbortSignal): AbortController {
return controller
}

function observeFocusTrap(container: HTMLElement, sentinels: HTMLElement[]) {
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const sentinelChildren = Array.from(mutation.addedNodes).filter(
e => e instanceof HTMLElement && e.classList.contains('sentinel') && e.tagName === 'SPAN',
)

// If any of the added nodes are sentinels, don't do anything
if (sentinelChildren.length) {
return
}
// If the first and last children of container aren't sentinels, move them to the start and end
const firstChild = container.firstElementChild
const lastChild = container.lastElementChild

const [sentinelStart, sentinelEnd] = sentinels

// Adds back sentinel to correct position in the DOM
if (!firstChild?.classList.contains('sentinel')) {
container.insertAdjacentElement('afterbegin', sentinelStart)
}
if (!lastChild?.classList.contains('sentinel')) {
container.insertAdjacentElement('beforeend', sentinelEnd)
}
}
}
})

observer.observe(container, {childList: true})

return observer
}

/**
* Traps focus within the given container
* @param container The container in which to trap focus
Expand Down Expand Up @@ -67,6 +101,8 @@ export function focusTrap(
container.prepend(sentinelStart)
container.append(sentinelEnd)

const observer = observeFocusTrap(container, [sentinelStart, sentinelEnd])

let lastFocusedChild: HTMLElement | undefined = undefined
// Ensure focus remains in the trap zone by checking that a given recently-focused
// element is inside the trap zone. If it isn't, redirect focus to a suitable
Expand Down Expand Up @@ -117,6 +153,7 @@ export function focusTrap(
if (suspendedTrapIndex >= 0) {
suspendedTrapStack.splice(suspendedTrapIndex, 1)
}
observer.disconnect()
tryReactivate()
})

Expand Down

0 comments on commit e7bb7b2

Please sign in to comment.