diff --git a/.changeset/curly-lions-cheat.md b/.changeset/curly-lions-cheat.md
new file mode 100644
index 0000000..8d11fce
--- /dev/null
+++ b/.changeset/curly-lions-cheat.md
@@ -0,0 +1,5 @@
+---
+'@primer/behaviors': patch
+---
+
+Adds mutation observer to `focus-trap` to ensure sentinel elements are always in the correct position
diff --git a/src/__tests__/focus-trap.test.tsx b/src/__tests__/focus-trap.test.tsx
index 68ed72e..0ad0039 100644
--- a/src/__tests__/focus-trap.test.tsx
+++ b/src/__tests__/focus-trap.test.tsx
@@ -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(
+
+
+
+
+
+
+
+
,
+ )
+
+ const trapContainer = container.querySelector('#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', '')
+ 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', '')
+ 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(
+
+
+
+
+
+
+
+
,
+ )
+
+ const trapContainer = container.querySelector('#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', '')
+ 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', '')
+ const newLastButton = trapContainer.querySelector('#last')
+
+ await user.tab({shift: true})
+ expect(document.activeElement).not.toEqual(newLastButton)
+ expect(trapContainer.lastElementChild).toEqual(newLastButton)
+})
diff --git a/src/focus-trap.ts b/src/focus-trap.ts
index c3de92e..395c35b 100644
--- a/src/focus-trap.ts
+++ b/src/focus-trap.ts
@@ -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
@@ -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
@@ -117,6 +153,7 @@ export function focusTrap(
if (suspendedTrapIndex >= 0) {
suspendedTrapStack.splice(suspendedTrapIndex, 1)
}
+ observer.disconnect()
tryReactivate()
})