Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/support-contenteditable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/behaviors": patch
---

Respect `contenteditable` elements when checking for editability in `focus-zone` behavior
41 changes: 21 additions & 20 deletions src/focus-zone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {polyfill as eventListenerSignalPolyfill} from './polyfills/event-listene
import {isMacOS} from './utils/user-agent.js'
import {IterateFocusableElements, iterateFocusableElements} from './utils/iterate-focusable-elements.js'
import {uniqueId} from './utils/unique-id.js'
import {isEditableElement} from './utils/is-editable-element.js'

eventListenerSignalPolyfill()

Expand Down Expand Up @@ -267,22 +268,16 @@ function shouldIgnoreFocusHandling(keyboardEvent: KeyboardEvent, activeElement:
// its function to move focus rather than type a <TAB> character.
const keyLength = [...key].length

const isTextInput =
(activeElement instanceof HTMLInputElement && activeElement.type === 'text') ||
activeElement instanceof HTMLTextAreaElement
const isEditable = isEditableElement(activeElement)

// If we would normally type a character into an input, ignore
// Also, Home and End keys should never affect focus when in a text input
if (isTextInput && (keyLength === 1 || key === 'Home' || key === 'End')) {
if (isEditable && (keyLength === 1 || key === 'Home' || key === 'End')) {
return true
}

// Some situations we want to ignore with <select> elements
// Some situations we specifically want to ignore with <select> elements
if (activeElement instanceof HTMLSelectElement) {
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of the single-character key handling for select elements is problematic. Select elements should ignore typeable characters (keyLength === 1) as they change the selection, but this logic was removed when switching to isEditable. This will cause focus zone navigation to trigger inappropriately when typing in select elements.

Suggested change
if (activeElement instanceof HTMLSelectElement) {
if (activeElement instanceof HTMLSelectElement) {
// Ignore single-character (typeable) keys, as they change the selection
if (keyLength === 1) {
return true
}

Copilot uses AI. Check for mistakes.

// Regular typeable characters change the selection, so ignore those
if (keyLength === 1) {
return true
}
// On macOS, bare ArrowDown opens the select, so ignore that
if (key === 'ArrowDown' && isMacOS() && !keyboardEvent.metaKey) {
return true
Expand All @@ -293,16 +288,15 @@ function shouldIgnoreFocusHandling(keyboardEvent: KeyboardEvent, activeElement:
}
}

// Ignore page up and page down for textareas
if (activeElement instanceof HTMLTextAreaElement && (key === 'PageUp' || key === 'PageDown')) {
return true
}

if (isTextInput) {
const textInput = activeElement as HTMLInputElement | HTMLTextAreaElement
const cursorAtStart = textInput.selectionStart === 0 && textInput.selectionEnd === 0
if (isEditable) {
// An editable element might not be an input element if it is a `contenteditable` element, but it's significantly
// harder and less reliable to get the caret position for those elements, so for them we just always ignore arrows
const isInputElement = activeElement instanceof HTMLTextAreaElement || activeElement instanceof HTMLInputElement
const cursorAtStart = isInputElement && activeElement.selectionStart === 0 && activeElement.selectionEnd === 0
const cursorAtEnd =
textInput.selectionStart === textInput.value.length && textInput.selectionEnd === textInput.value.length
isInputElement &&
activeElement.selectionStart === activeElement.value.length &&
activeElement.selectionEnd === activeElement.value.length

// When in a text area or text input, only move focus left/right if at beginning/end of the field
if (key === 'ArrowLeft' && !cursorAtStart) {
Expand All @@ -312,8 +306,15 @@ function shouldIgnoreFocusHandling(keyboardEvent: KeyboardEvent, activeElement:
return true
}

// When in a text area, only move focus up/down if at beginning/end of the field
if (textInput instanceof HTMLTextAreaElement) {
const isContentEditable = activeElement instanceof HTMLElement && activeElement.isContentEditable

// When in multiline inputs
if (activeElement instanceof HTMLTextAreaElement || isContentEditable) {
// Always ignore page up/down
if (key === 'PageUp' || key === 'PageDown') {
return true
}
// Only move focus up/down if at beginning/end of the field
if (key === 'ArrowUp' && !cursorAtStart) {
return true
}
Expand Down
35 changes: 35 additions & 0 deletions src/utils/is-editable-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const nonEditableInputTypes = new Set([
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot detected a code snippet with 2 occurrences. See search results for more details.

Matched Code Snippet
,
  'checkbox',
  'color',
  'file',
  'hidden',
  'image',
  'radio',
  'range',
  'reset',
  'submit',
])

/**
 * Returns true if `element` is editable - that is, if it can be focused and typed in like an input or textarea.
 */
export function isEditableElement(target: EventTarget | null): boolean {
  if (!(target instanceof HTMLElement)) return false

  const name = target.nodeName.toLowerCase()
  const type = target.getAttribute('type')?.toLowerCase() ?? 'text'

  const isReadonly =
    target.ariaReadOnly === 'true' ||
    target.getAttribute('aria-readonly') === 'true' ||
    target.getAttribute('readonly') !== null

  return (
    (name === 'select' ||
      name === 'textarea' ||
      (name === 'input' && !nonEditableInputTypes.has(type)) ||
      target

Copilot uses AI. Check for mistakes.

'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
])

/**
* Returns true if `element` is editable - that is, if it can be focused and typed in like an input or textarea.
*/
export function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false

const name = target.nodeName.toLowerCase()
const type = target.getAttribute('type')?.toLowerCase() ?? 'text'

const isReadonly =
target.ariaReadOnly === 'true' ||
target.getAttribute('aria-readonly') === 'true' ||
Copy link
Preview

Copilot AI Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The readonly check is duplicated between ariaReadOnly property and aria-readonly attribute. The ariaReadOnly property should already reflect the aria-readonly attribute value, making the second check redundant.

Suggested change
target.getAttribute('aria-readonly') === 'true' ||

Copilot uses AI. Check for mistakes.

target.getAttribute('readonly') !== null

return (
(name === 'select' ||
name === 'textarea' ||
(name === 'input' && !nonEditableInputTypes.has(type)) ||
target.isContentEditable) &&
!isReadonly
)
}
Loading