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
+
+ ,
+ )
+
+ 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(
+
+
+
+
+
+
Hello
+
Not 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(Apple )
+ const focusable = isFocusable(container.firstChild as HTMLElement)
+ expect(focusable).toBeTruthy()
+ })
+
+ it('disabled attr is not focusable', async () => {
+ const {container} = render(Apple )
+ const focusable = isFocusable(container.firstChild as HTMLElement)
+ expect(focusable).toBeFalsy()
+ })
+
+ it('hidden attr is not focusable', async () => {
+ const {container} = render(Apple )
+ const focusable = isFocusable(container.firstChild as HTMLElement)
+ expect(focusable).toBeFalsy()
+ })
+
+ it('tabIndex -1 is still focusable', async () => {
+ const {container} = render(Apple )
+ 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(
+ Apple ,
+ )
+ const focusable = isFocusable(container.firstChild as HTMLElement)
+ expect(focusable).toBeTruthy()
+ })
+
+ it('reflow attributes are not be focusable when strict', async () => {
+ const {container} = render(
+ Apple ,
+ )
+ const focusable = isFocusable(container.firstChild as HTMLElement, true)
+ expect(focusable).toBeFalsy()
+ })
+})
+
+describe('isTabbable', () => {
+ it('tabIndex 0 is tabbable', async () => {
+ const {container} = render(Apple )
+ const tabbable = isTabbable(container.firstChild as HTMLElement)
+ expect(tabbable).toBeTruthy()
+ })
+
+ it('tabIndex -1 is not tabbable', async () => {
+ const {container} = render(Apple )
+ const tabbable = isTabbable(container.firstChild as HTMLElement)
+ expect(tabbable).toBeFalsy()
+ })
+
+ it('Should not be tabbable when strict', async () => {
+ const {container} = render(Apple )
+ 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