Skip to content

Commit e7bb7b2

Browse files
authored
Merge pull request #429 from primer/tylerjdev/add-observer-to-focus-trap
Add observer to focus-trap
2 parents b1b531d + 07e1664 commit e7bb7b2

File tree

3 files changed

+130
-0
lines changed

3 files changed

+130
-0
lines changed

.changeset/curly-lions-cheat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/behaviors': patch
3+
---
4+
5+
Adds mutation observer to `focus-trap` to ensure sentinel elements are always in the correct position

src/__tests__/focus-trap.test.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,91 @@ it('Should handle dynamic content', async () => {
240240

241241
controller?.abort()
242242
})
243+
244+
it('should keep the sentinel elements at the start/end of the inner container', async () => {
245+
const user = userEvent.setup()
246+
const {container} = render(
247+
<div>
248+
<div id="trapContainer">
249+
<button tabIndex={0}>Apple</button>
250+
<button tabIndex={0}>Banana</button>
251+
<button tabIndex={0}>Cantaloupe</button>
252+
</div>
253+
<button id="durian" tabIndex={0}>
254+
Durian
255+
</button>
256+
</div>,
257+
)
258+
259+
const trapContainer = container.querySelector<HTMLElement>('#trapContainer')!
260+
const [firstButton, secondButton] = trapContainer.querySelectorAll('button')
261+
const controller = focusTrap(trapContainer)
262+
263+
secondButton.focus()
264+
await user.tab()
265+
await user.tab()
266+
expect(document.activeElement).toEqual(firstButton)
267+
268+
trapContainer.insertAdjacentHTML('afterbegin', '<button id="first" tabindex="0">New first button</button>')
269+
const newFirstButton = trapContainer.querySelector('#first')
270+
271+
const sentinelStart = trapContainer.querySelector('.sentinel')
272+
273+
await user.tab({shift: true})
274+
expect(trapContainer.firstElementChild).toEqual(sentinelStart)
275+
expect(document.activeElement).toEqual(newFirstButton)
276+
277+
trapContainer.insertAdjacentHTML('beforeend', '<button id="last" tabindex="0">New last button</button>')
278+
const newLastButton = trapContainer.querySelector('#last')
279+
280+
const sentinelEnd = trapContainer.querySelector('.sentinel')
281+
282+
await user.tab({shift: true})
283+
expect(trapContainer.lastElementChild).toEqual(sentinelEnd)
284+
expect(document.activeElement).toEqual(newLastButton)
285+
286+
controller?.abort()
287+
})
288+
289+
it('should remove the mutation observer when the focus trap is released', async () => {
290+
const user = userEvent.setup()
291+
const {container} = render(
292+
<div>
293+
<div id="trapContainer">
294+
<button tabIndex={0}>Apple</button>
295+
<button tabIndex={0}>Banana</button>
296+
<button tabIndex={0}>Cantaloupe</button>
297+
</div>
298+
<button id="durian" tabIndex={0}>
299+
Durian
300+
</button>
301+
</div>,
302+
)
303+
304+
const trapContainer = container.querySelector<HTMLElement>('#trapContainer')!
305+
const [firstButton, secondButton] = trapContainer.querySelectorAll('button')
306+
const controller = focusTrap(trapContainer)
307+
308+
secondButton.focus()
309+
await user.tab()
310+
await user.tab()
311+
expect(document.activeElement).toEqual(firstButton)
312+
313+
trapContainer.insertAdjacentHTML('afterbegin', '<button id="first" tabindex="0">New first button</button>')
314+
const newFirstButton = trapContainer.querySelector('#first')
315+
316+
const sentinelStart = trapContainer.querySelector('.sentinel')
317+
318+
await user.tab({shift: true})
319+
expect(trapContainer.firstElementChild).toEqual(sentinelStart)
320+
expect(document.activeElement).toEqual(newFirstButton)
321+
322+
controller?.abort()
323+
324+
trapContainer.insertAdjacentHTML('beforeend', '<button id="last" tabindex="0">New last button</button>')
325+
const newLastButton = trapContainer.querySelector('#last')
326+
327+
await user.tab({shift: true})
328+
expect(document.activeElement).not.toEqual(newLastButton)
329+
expect(trapContainer.lastElementChild).toEqual(newLastButton)
330+
})

src/focus-trap.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,40 @@ function followSignal(signal: AbortSignal): AbortController {
3030
return controller
3131
}
3232

33+
function observeFocusTrap(container: HTMLElement, sentinels: HTMLElement[]) {
34+
const observer = new MutationObserver(mutations => {
35+
for (const mutation of mutations) {
36+
if (mutation.type === 'childList' && mutation.addedNodes.length) {
37+
const sentinelChildren = Array.from(mutation.addedNodes).filter(
38+
e => e instanceof HTMLElement && e.classList.contains('sentinel') && e.tagName === 'SPAN',
39+
)
40+
41+
// If any of the added nodes are sentinels, don't do anything
42+
if (sentinelChildren.length) {
43+
return
44+
}
45+
// If the first and last children of container aren't sentinels, move them to the start and end
46+
const firstChild = container.firstElementChild
47+
const lastChild = container.lastElementChild
48+
49+
const [sentinelStart, sentinelEnd] = sentinels
50+
51+
// Adds back sentinel to correct position in the DOM
52+
if (!firstChild?.classList.contains('sentinel')) {
53+
container.insertAdjacentElement('afterbegin', sentinelStart)
54+
}
55+
if (!lastChild?.classList.contains('sentinel')) {
56+
container.insertAdjacentElement('beforeend', sentinelEnd)
57+
}
58+
}
59+
}
60+
})
61+
62+
observer.observe(container, {childList: true})
63+
64+
return observer
65+
}
66+
3367
/**
3468
* Traps focus within the given container
3569
* @param container The container in which to trap focus
@@ -67,6 +101,8 @@ export function focusTrap(
67101
container.prepend(sentinelStart)
68102
container.append(sentinelEnd)
69103

104+
const observer = observeFocusTrap(container, [sentinelStart, sentinelEnd])
105+
70106
let lastFocusedChild: HTMLElement | undefined = undefined
71107
// Ensure focus remains in the trap zone by checking that a given recently-focused
72108
// element is inside the trap zone. If it isn't, redirect focus to a suitable
@@ -117,6 +153,7 @@ export function focusTrap(
117153
if (suspendedTrapIndex >= 0) {
118154
suspendedTrapStack.splice(suspendedTrapIndex, 1)
119155
}
156+
observer.disconnect()
120157
tryReactivate()
121158
})
122159

0 commit comments

Comments
 (0)