diff --git a/.changeset/many-ducks-travel.md b/.changeset/many-ducks-travel.md new file mode 100644 index 0000000..5a755ff --- /dev/null +++ b/.changeset/many-ducks-travel.md @@ -0,0 +1,5 @@ +--- +'@primer/behaviors': minor +--- + +Add support for contenteditable to iterateFocusableElements diff --git a/src/__tests__/iterate-focusable-elements.test.tsx b/src/__tests__/iterate-focusable-elements.test.tsx index 36bad14..e62b148 100644 --- a/src/__tests__/iterate-focusable-elements.test.tsx +++ b/src/__tests__/iterate-focusable-elements.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {iterateFocusableElements} from '../utils/iterate-focusable-elements.js' +import {isFocusable, isTabbable, iterateFocusableElements} from '../utils/iterate-focusable-elements.js' import {render} from '@testing-library/react' // Since we use strict checks for size and parent, we need to mock these @@ -53,24 +53,58 @@ it('Should iterate through focusable elements only', () => {

Not focusable

- Not focusable + Focusable Focusable
+
+ , + ) + + const focusable = Array.from(iterateFocusableElements(container as HTMLElement)) + expect(focusable.length).toEqual(7) + expect(focusable[0].tagName.toLowerCase()).toEqual('textarea') + expect(focusable[1].tagName.toLowerCase()).toEqual('input') + expect(focusable[2].tagName.toLowerCase()).toEqual('button') + expect(focusable[3].tagName.toLowerCase()).toEqual('div') + expect(focusable[4].tagName.toLowerCase()).toEqual('a') + expect(focusable[4].getAttribute('href')).toEqual('#boo') + expect(focusable[5].tagName.toLowerCase()).toEqual('a') + expect(focusable[5].getAttribute('href')).toEqual('#yah') + expect(focusable[6].tagName.toLowerCase()).toEqual('blockquote') +}) + +it('Should iterate through tabbable elements only', () => { + const {container} = render( +
+
+ +
+ + +

Not tabbable

+
+ + Not tabbable + + Tabbable +
+
, ) const focusable = Array.from(iterateFocusableElements(container as HTMLElement, {onlyTabbable: true})) - expect(focusable.length).toEqual(5) + expect(focusable.length).toEqual(6) expect(focusable[0].tagName.toLowerCase()).toEqual('textarea') expect(focusable[1].tagName.toLowerCase()).toEqual('input') expect(focusable[2].tagName.toLowerCase()).toEqual('button') expect(focusable[3].tagName.toLowerCase()).toEqual('div') expect(focusable[4].tagName.toLowerCase()).toEqual('a') expect(focusable[4].getAttribute('href')).toEqual('#yah') + expect(focusable[5].tagName.toLowerCase()).toEqual('blockquote') }) -it('Should iterate through focusable elements in reverse', () => { +it('Should iterate through tab elements in reverse', () => { const {container} = render(
@@ -85,17 +119,19 @@ it('Should iterate through focusable elements in reverse', () => { Focusable
+
, ) const focusable = Array.from(iterateFocusableElements(container as HTMLElement, {reverse: true, onlyTabbable: true})) - expect(focusable.length).toEqual(5) - expect(focusable[0].tagName.toLowerCase()).toEqual('a') - expect(focusable[0].getAttribute('href')).toEqual('#yah') - expect(focusable[1].tagName.toLowerCase()).toEqual('div') - expect(focusable[2].tagName.toLowerCase()).toEqual('button') - expect(focusable[3].tagName.toLowerCase()).toEqual('input') - expect(focusable[4].tagName.toLowerCase()).toEqual('textarea') + expect(focusable.length).toEqual(6) + expect(focusable[0].tagName.toLowerCase()).toEqual('blockquote') + expect(focusable[1].tagName.toLowerCase()).toEqual('a') + expect(focusable[1].getAttribute('href')).toEqual('#yah') + expect(focusable[2].tagName.toLowerCase()).toEqual('div') + expect(focusable[3].tagName.toLowerCase()).toEqual('button') + expect(focusable[4].tagName.toLowerCase()).toEqual('input') + expect(focusable[5].tagName.toLowerCase()).toEqual('textarea') }) it('Should ignore hidden elements if strict', async () => { @@ -124,3 +160,77 @@ it('Should ignore hidden elements if strict', async () => { expect(focusable[1].tagName.toLowerCase()).toEqual('button') expect(focusable[1].textContent).toEqual('Cantaloupe') }) + +describe('isFocusable', () => { + it('checks if element is focusable', async () => { + const {container} = render() + const focusable = isFocusable(container.firstChild as HTMLElement) + expect(focusable).toBeTruthy() + }) + + it('disabled attr is not focusable', async () => { + const {container} = render() + const focusable = isFocusable(container.firstChild as HTMLElement) + expect(focusable).toBeFalsy() + }) + + it('hidden attr is not focusable', async () => { + const {container} = render() + const focusable = isFocusable(container.firstChild as HTMLElement) + expect(focusable).toBeFalsy() + }) + + it('tabIndex -1 is still focusable', async () => { + const {container} = render() + const focusable = isFocusable(container.firstChild as HTMLElement) + expect(focusable).toBeTruthy() + }) + + it('contenteditable is focusable', async () => { + const {container} = render(
) + const focusable = isFocusable(container.firstChild as HTMLElement) + expect(focusable).toBeTruthy() + }) + + it('anchor with no href is not focusable', async () => { + const {container} = render(Apple) + const focusable = isFocusable(container.firstChild as HTMLElement) + expect(focusable).toBeFalsy() + }) + + it('reflow attrributes is focusable', async () => { + const {container} = render( + , + ) + const focusable = isFocusable(container.firstChild as HTMLElement) + expect(focusable).toBeTruthy() + }) + + it('reflow attributes are not be focusable when strict', async () => { + const {container} = render( + , + ) + const focusable = isFocusable(container.firstChild as HTMLElement, true) + expect(focusable).toBeFalsy() + }) +}) + +describe('isTabbable', () => { + it('tabIndex 0 is tabbable', async () => { + const {container} = render() + const tabbable = isTabbable(container.firstChild as HTMLElement) + expect(tabbable).toBeTruthy() + }) + + it('tabIndex -1 is not tabbable', async () => { + const {container} = render() + const tabbable = isTabbable(container.firstChild as HTMLElement) + expect(tabbable).toBeFalsy() + }) + + it('Should not be tabbable when strict', async () => { + const {container} = render() + const tabbable = isTabbable(container.firstChild as HTMLElement, true) + expect(tabbable).toBeFalsy() + }) +}) diff --git a/src/utils/iterate-focusable-elements.ts b/src/utils/iterate-focusable-elements.ts index 3a4a58d..ce6b23d 100644 --- a/src/utils/iterate-focusable-elements.ts +++ b/src/utils/iterate-focusable-elements.ts @@ -113,6 +113,10 @@ export function isFocusable(elem: HTMLElement, strict = false): boolean { return true } + if (elem.getAttribute('contenteditable') === 'true' || elem.getAttribute('contenteditable') === 'plaintext-only') { + return true + } + // One last way `elem.tabIndex` can be wrong. if (elem instanceof HTMLAnchorElement && elem.getAttribute('href') == null) { return false