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() })