Skip to content

Commit

Permalink
Merge pull request #241 from primer/jellobagel-contenteditable
Browse files Browse the repository at this point in the history
Add `contenteditable` to iterateFocusableElements
  • Loading branch information
JelloBagel authored Oct 31, 2023
2 parents 6ed742f + cb98837 commit 9cf2644
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/many-ducks-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/behaviors': minor
---

Add support for contenteditable to iterateFocusableElements
132 changes: 121 additions & 11 deletions src/__tests__/iterate-focusable-elements.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -53,24 +53,58 @@ it('Should iterate through focusable elements only', () => {
<p>Not focusable</p>
<div tabIndex={0}>
<a tabIndex={-1} href="#boo">
Not focusable
Focusable
</a>
<a href="#yah">Focusable</a>
</div>
<blockquote contentEditable></blockquote>
</div>,
)

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(
<div>
<div>
<textarea></textarea>
</div>
<input />
<button>Hello</button>
<p>Not tabbable</p>
<div tabIndex={0}>
<a tabIndex={-1} href="#boo">
Not tabbable
</a>
<a href="#yah">Tabbable</a>
</div>
<blockquote contentEditable></blockquote>
</div>,
)

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(
<div>
<div>
Expand All @@ -85,17 +119,19 @@ it('Should iterate through focusable elements in reverse', () => {
</a>
<a href="#yah">Focusable</a>
</div>
<blockquote contentEditable></blockquote>
</div>,
)

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 () => {
Expand Down Expand Up @@ -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(<button>Apple</button>)
const focusable = isFocusable(container.firstChild as HTMLElement)
expect(focusable).toBeTruthy()
})

it('disabled attr is not focusable', async () => {
const {container} = render(<button disabled>Apple</button>)
const focusable = isFocusable(container.firstChild as HTMLElement)
expect(focusable).toBeFalsy()
})

it('hidden attr is not focusable', async () => {
const {container} = render(<button hidden>Apple</button>)
const focusable = isFocusable(container.firstChild as HTMLElement)
expect(focusable).toBeFalsy()
})

it('tabIndex -1 is still focusable', async () => {
const {container} = render(<button tabIndex={-1}>Apple</button>)
const focusable = isFocusable(container.firstChild as HTMLElement)
expect(focusable).toBeTruthy()
})

it('contenteditable is focusable', async () => {
const {container} = render(<blockquote contentEditable></blockquote>)
const focusable = isFocusable(container.firstChild as HTMLElement)
expect(focusable).toBeTruthy()
})

it('anchor with no href is not focusable', async () => {
const {container} = render(<a>Apple</a>)
const focusable = isFocusable(container.firstChild as HTMLElement)
expect(focusable).toBeFalsy()
})

it('reflow attrributes is focusable', async () => {
const {container} = render(
<button style={{display: 'none', visibility: 'hidden', width: 0, height: 0}}>Apple</button>,
)
const focusable = isFocusable(container.firstChild as HTMLElement)
expect(focusable).toBeTruthy()
})

it('reflow attributes are not be focusable when strict', async () => {
const {container} = render(
<button style={{display: 'none', visibility: 'hidden', width: 0, height: 0}}>Apple</button>,
)
const focusable = isFocusable(container.firstChild as HTMLElement, true)
expect(focusable).toBeFalsy()
})
})

describe('isTabbable', () => {
it('tabIndex 0 is tabbable', async () => {
const {container} = render(<button tabIndex={0}>Apple</button>)
const tabbable = isTabbable(container.firstChild as HTMLElement)
expect(tabbable).toBeTruthy()
})

it('tabIndex -1 is not tabbable', async () => {
const {container} = render(<button tabIndex={-1}>Apple</button>)
const tabbable = isTabbable(container.firstChild as HTMLElement)
expect(tabbable).toBeFalsy()
})

it('Should not be tabbable when strict', async () => {
const {container} = render(<button style={{display: 'none'}}>Apple</button>)
const tabbable = isTabbable(container.firstChild as HTMLElement, true)
expect(tabbable).toBeFalsy()
})
})
4 changes: 4 additions & 0 deletions src/utils/iterate-focusable-elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 9cf2644

Please sign in to comment.