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

- 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() + }) +}) 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