diff --git a/demos/src/Examples/Accessibility/React/InsertMenu.tsx b/demos/src/Examples/Accessibility/React/InsertMenu.tsx
index 957c9aff4..382b89c7f 100644
--- a/demos/src/Examples/Accessibility/React/InsertMenu.tsx
+++ b/demos/src/Examples/Accessibility/React/InsertMenu.tsx
@@ -1,7 +1,7 @@
import { Editor, FloatingMenu, useEditorState } from '@tiptap/react'
import React, { useRef } from 'react'
-import { useFocusMenubar } from './useFocusMenubar.js'
+import { useMenubarNav } from './useMenubarNav.js'
/**
* A floating menu for inserting new elements like lists, horizontal rules, etc.
@@ -21,10 +21,11 @@ export function InsertMenu({ editor }: { editor: Editor }) {
})
// Handle arrow navigation within a menu bar container, and allow to escape to the editor
- const { focusButton } = useFocusMenubar({
+ const { getFocusableElements } = useMenubarNav({
editor,
ref: containerRef,
- onEscape: () => {
+ onEscape: e => {
+ e.preventDefault()
// On escape, focus the editor
editor.chain().focus().run()
},
@@ -43,8 +44,8 @@ export function InsertMenu({ editor }: { editor: Editor }) {
onFocus={e => {
// The ref we have is to the container, not the menu itself
if (containerRef.current === e.target?.parentNode) {
- // Focus the first button when the menu bar is focused
- focusButton(containerRef.current?.querySelector('button'))
+ // Focus the first enabled button-like when the menu bar is focused
+ getFocusableElements()?.[0]?.focus()
}
}}
tabIndex={0}
diff --git a/demos/src/Examples/Accessibility/React/MenuBar.tsx b/demos/src/Examples/Accessibility/React/MenuBar.tsx
index 00495cfd0..b2473125f 100644
--- a/demos/src/Examples/Accessibility/React/MenuBar.tsx
+++ b/demos/src/Examples/Accessibility/React/MenuBar.tsx
@@ -2,7 +2,7 @@ import { Editor } from '@tiptap/core'
import { useEditorState } from '@tiptap/react'
import React, { useEffect, useRef, useState } from 'react'
-import { useFocusMenubar } from './useFocusMenubar.js'
+import { useMenubarNav } from './useMenubarNav.js'
/**
* Handles the heading dropdown
@@ -48,10 +48,24 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) {
}, [])
return (
-
+
{
+ // Escape or tab should close the dropdown if it's open
+ if (isOpen && (e.key === 'Escape' || e.key === 'Tab')) {
+ setIsOpen(false)
+ if (e.key === 'Escape') {
+ // Prevent the editor from handling the escape key
+ e.preventDefault()
+ }
+ }
+ }}
+ >
{
+ onClick={e => {
setIsOpen(open => !open)
+ e.stopPropagation()
}}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -257,7 +271,7 @@ export function MenuBar({ editor }: { editor: Editor }) {
},
})
- useFocusMenubar({
+ useMenubarNav({
ref: containerRef,
editor,
onKeydown: event => {
diff --git a/demos/src/Examples/Accessibility/React/TextMenu.tsx b/demos/src/Examples/Accessibility/React/TextMenu.tsx
index e565114d2..fe94d4842 100644
--- a/demos/src/Examples/Accessibility/React/TextMenu.tsx
+++ b/demos/src/Examples/Accessibility/React/TextMenu.tsx
@@ -2,7 +2,7 @@ import { Selection } from '@tiptap/pm/state'
import { BubbleMenu, Editor, useEditorState } from '@tiptap/react'
import React, { useRef } from 'react'
-import { useFocusMenubar } from './useFocusMenubar.js'
+import { useMenubarNav } from './useMenubarNav.js'
/**
* Handles formatting text with marks like bold, italic, etc.
@@ -33,10 +33,11 @@ export function TextMenu({ editor }: { editor: Editor }) {
})
// Handle arrow navigation within a menu bar container, and allow to escape to the editor
- const { focusButton } = useFocusMenubar({
+ const { getFocusableElements } = useMenubarNav({
editor,
ref: containerRef,
- onEscape: () => {
+ onEscape: e => {
+ e.preventDefault()
// On escape, focus the editor & dismiss the menu by moving the selection to the end of the selection
editor
.chain()
@@ -63,7 +64,7 @@ export function TextMenu({ editor }: { editor: Editor }) {
// The ref we have is to the container, not the menu itself
if (containerRef.current === e.target?.parentNode) {
// Focus the first button when the menu bar is focused
- focusButton(containerRef.current?.querySelector('button'))
+ getFocusableElements()?.[0]?.focus()
}
}}
tabIndex={0}
diff --git a/demos/src/Examples/Accessibility/React/useFocusMenubar.ts b/demos/src/Examples/Accessibility/React/useFocusMenubar.ts
deleted file mode 100644
index 214e44d9f..000000000
--- a/demos/src/Examples/Accessibility/React/useFocusMenubar.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import { Editor } from '@tiptap/core'
-import React, { useCallback, useEffect, useRef } from 'react'
-
-/**
- * Handle arrow navigation within a menu bar container, and allow to escape to the editor
- */
-export function useFocusMenubar({
- ref: containerRef,
- editor,
- onEscape = e => {
- e.commands.focus()
- },
- onKeydown = () => {
- // Do nothing
- },
-}: {
- /**
- * Ref to the menu bar container
- */
- ref: React.RefObject;
- /**
- * The editor instance
- */
- editor: Editor;
- /**
- * Callback when the user presses the escape key
- */
- onEscape?: (editor: Editor) => void;
- /**
- * Callback when a keyboard event occurs
- * @note Call `event.preventDefault()` to prevent the default behavior
- */
- onKeydown?: (
- event: KeyboardEvent,
- ctx: { editor: Editor; ref: React.RefObject }
- ) => void;
-}) {
- const callbacks = useRef({
- onEscape,
- onKeydown,
- })
-
- // Always take the latest callback
- callbacks.current = {
- onEscape,
- onKeydown,
- }
-
- const focusNextButton = useCallback(
- (el = document.activeElement) => {
- if (!containerRef.current) {
- return null
- }
-
- const elements = Array.from(containerRef.current.querySelectorAll('button'))
- const index = elements.findIndex(element => element === el)
-
- // Find the next enabled button
- for (let i = index + 1; i <= elements.length; i += 1) {
- if (!elements[i % elements.length].disabled) {
- elements[i % elements.length].focus()
- return elements[i % elements.length]
- }
- }
- return null
- },
- [containerRef],
- )
-
- const focusPreviousButton = useCallback(
- (el = document.activeElement) => {
- if (!containerRef.current) {
- return null
- }
-
- const elements = Array.from(containerRef.current.querySelectorAll('button'))
- const index = elements.findIndex(element => element === el)
-
- // Find the previous enabled button
- for (let i = index - 1; i >= -1; i -= 1) {
- // If we reach the beginning, start from the end
- if (i < 0) {
- i = elements.length - 1
- }
- if (!elements[i].disabled) {
- elements[i].focus()
- return elements[i]
- }
- }
- return null
- },
- [containerRef],
- )
-
- const focusButton = useCallback(
- (
- el: HTMLButtonElement | null | undefined,
- direction: 'forwards' | 'backwards' = 'forwards',
- ) => {
- if (!el) {
- return
- }
- if (!el.disabled) {
- el.focus()
- return
- }
- if (direction === 'forwards') {
- focusNextButton(el)
- }
- if (direction === 'backwards') {
- focusPreviousButton(el)
- }
- },
- [focusNextButton, focusPreviousButton],
- )
-
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (!containerRef.current) {
- return
- }
-
- callbacks.current.onKeydown(event, { editor, ref: containerRef })
- if (event.defaultPrevented) {
- return
- }
-
- const elements = Array.from(containerRef.current.querySelectorAll('button'))
- const isFocusedOnButton = elements.includes(event.target as HTMLButtonElement)
-
- if (isFocusedOnButton || event.target === containerRef.current) {
- // Allow to escape to the editor
- if (event.key === 'Escape') {
- event.preventDefault()
- callbacks.current.onEscape(editor)
- return true
- }
- // Handle arrow navigation within the menu bar
- if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
- if (focusNextButton(event.target as HTMLButtonElement)) {
- event.preventDefault()
- return true
- }
- }
-
- if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
- if (focusPreviousButton(event.target as HTMLButtonElement)) {
- event.preventDefault()
- return true
- }
- }
- }
- }
-
- window.addEventListener('keydown', handleKeyDown)
-
- return () => {
- window.removeEventListener('keydown', handleKeyDown)
- }
- }, [containerRef, editor, focusNextButton, focusPreviousButton])
-
- return { focusNextButton, focusPreviousButton, focusButton }
-}
diff --git a/demos/src/Examples/Accessibility/React/useMenubarNav.ts b/demos/src/Examples/Accessibility/React/useMenubarNav.ts
new file mode 100644
index 000000000..cab99dcd7
--- /dev/null
+++ b/demos/src/Examples/Accessibility/React/useMenubarNav.ts
@@ -0,0 +1,128 @@
+import { Editor } from '@tiptap/core'
+import React, { useCallback, useEffect, useRef } from 'react'
+
+/**
+ * Handle arrow navigation within a menu bar container, and allow to escape to the editor
+ */
+export function useMenubarNav({
+ ref: containerRef,
+ editor,
+ onEscape = (e, ctx) => {
+ e.preventDefault()
+ ctx.editor.commands.focus()
+ },
+ onKeydown = () => {
+ // Do nothing
+ },
+}: {
+ /**
+ * Ref to the menu bar container
+ */
+ ref: React.RefObject;
+ /**
+ * The editor instance
+ */
+ editor: Editor;
+ /**
+ * Callback when the user presses the escape key
+ */
+ onEscape?: (
+ event: KeyboardEvent,
+ ctx: { editor: Editor; ref: React.RefObject }
+ ) => void;
+ /**
+ * Callback when a keyboard event occurs
+ * @note Call `event.preventDefault()` to prevent the default behavior
+ */
+ onKeydown?: (
+ event: KeyboardEvent,
+ ctx: { editor: Editor; ref: React.RefObject }
+ ) => void;
+}) {
+ const callbacks = useRef({
+ onEscape,
+ onKeydown,
+ })
+
+ // Always take the latest callback
+ callbacks.current = {
+ onEscape,
+ onKeydown,
+ }
+
+ const getFocusableElements = useCallback(() => {
+ if (!containerRef.current) {
+ return []
+ }
+
+ return Array.from(
+ containerRef.current.querySelectorAll(
+ 'button, [role="button"], [tabindex="0"]',
+ ),
+ ).filter(el => !el.hasAttribute('disabled'))
+ }, [containerRef])
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!containerRef.current) {
+ return
+ }
+
+ callbacks.current.onKeydown(event, { editor, ref: containerRef })
+ if (event.defaultPrevented) {
+ return
+ }
+
+ const elements = getFocusableElements()
+ const isWithinContainer = containerRef.current.contains(event.target as HTMLElement)
+
+ if (isWithinContainer) {
+ // Allow to escape to the editor
+ if (event.key === 'Escape') {
+ callbacks.current.onEscape(event, { editor, ref: containerRef })
+ if (event.defaultPrevented) {
+ return true
+ }
+ }
+
+ // Move to the first element in the menu bar
+ if (event.key === 'Home') {
+ event.preventDefault()
+ elements[0].focus()
+ return true
+ }
+
+ // Move to the last element in the menu bar
+ if (event.key === 'End') {
+ event.preventDefault()
+ elements[elements.length - 1].focus()
+ return true
+ }
+
+ // Move forward in the menu bar
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
+ const element = elements.indexOf(event.target as HTMLElement)
+
+ elements[(element + 1) % elements.length].focus()
+ event.preventDefault()
+ }
+
+ // Move backward in the menu bar
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
+ const element = elements.indexOf(event.target as HTMLElement)
+
+ elements[(element - 1 + elements.length) % elements.length].focus()
+ event.preventDefault()
+ }
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [containerRef, editor, getFocusableElements])
+
+ return { getFocusableElements }
+}