diff --git a/.changeset/eighty-steaks-camp.md b/.changeset/eighty-steaks-camp.md new file mode 100644 index 0000000..d06107d --- /dev/null +++ b/.changeset/eighty-steaks-camp.md @@ -0,0 +1,5 @@ +--- +'@primer/behaviors': minor +--- + +Adjusts mutation observer to now track `hidden` and `disabled` attributes being applied or removed. diff --git a/src/__tests__/focus-zone.test.tsx b/src/__tests__/focus-zone.test.tsx index 9d94cbb..aa51c3a 100644 --- a/src/__tests__/focus-zone.test.tsx +++ b/src/__tests__/focus-zone.test.tsx @@ -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( +
+ + + +
, + ) + + const focusZoneContainer = container.querySelector('#focusZone')! + const [firstButton, , thirdButton] = focusZoneContainer.querySelectorAll('button') + const controller = focusZone(focusZoneContainer) + + firstButton.focus() + expect(document.activeElement).toEqual(firstButton) + + rerender( +
+ + + +
, + ) + + 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( +
+ + + +
, + ) + + const focusZoneContainer = container.querySelector('#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( +
+ + + +
, + ) + + 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( +
+ + + +
, + ) + + const focusZoneContainer = container.querySelector('#focusZone')! + const [firstButton, , thirdButton] = focusZoneContainer.querySelectorAll('button') + const controller = focusZone(focusZoneContainer) + + firstButton.focus() + expect(document.activeElement).toEqual(firstButton) + + rerender( +
+ + + +
, + ) + + await user.keyboard('{arrowdown}') + expect(document.activeElement).toEqual(thirdButton) + + controller.abort() +}) diff --git a/src/focus-zone.ts b/src/focus-zone.ts index 157c42d..1ee75c0 100644 --- a/src/focus-zone.ts +++ b/src/focus-zone.ts @@ -527,6 +527,12 @@ 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) { @@ -534,12 +540,22 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings): 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()