From f8ad3995e0df8511f88ab428b22837b7dced4c5c Mon Sep 17 00:00:00 2001
From: Stephanie Hong <41085564+JelloBagel@users.noreply.github.com>
Date: Fri, 27 Oct 2023 22:49:22 +0000
Subject: [PATCH 1/3] Add contenteditable to focusable elements
---
src/utils/iterate-focusable-elements.ts | 4 ++++
1 file changed, 4 insertions(+)
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
From 93d44f1327055f6a6f235efc225c6be7808515ff Mon Sep 17 00:00:00 2001
From: Stephanie Hong <41085564+JelloBagel@users.noreply.github.com>
Date: Fri, 27 Oct 2023 22:49:35 +0000
Subject: [PATCH 2/3] Add isFocusable and isTabbable tests
---
.../iterate-focusable-elements.test.tsx | 132 ++++++++++++++++--
1 file changed, 121 insertions(+), 11 deletions(-)
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(
+
+
+
+
+
+
+
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()
+ 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()
+ })
+})
From cb98837982b3b7a2c46a56639116dc80c6071a09 Mon Sep 17 00:00:00 2001
From: Stephanie Hong <41085564+JelloBagel@users.noreply.github.com>
Date: Fri, 27 Oct 2023 22:57:13 +0000
Subject: [PATCH 3/3] Add changeset
---
.changeset/many-ducks-travel.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/many-ducks-travel.md
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