mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-24 03:31:47 +08:00
refactor: cleanup implementation
This commit is contained in:
parent
2766d6f975
commit
ed89305e1a
@ -1,7 +1,7 @@
|
|||||||
import { Editor, FloatingMenu, useEditorState } from '@tiptap/react'
|
import { Editor, FloatingMenu, useEditorState } from '@tiptap/react'
|
||||||
import React, { useRef } from '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.
|
* 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
|
// Handle arrow navigation within a menu bar container, and allow to escape to the editor
|
||||||
const { focusButton } = useFocusMenubar({
|
const { getFocusableElements } = useMenubarNav({
|
||||||
editor,
|
editor,
|
||||||
ref: containerRef,
|
ref: containerRef,
|
||||||
onEscape: () => {
|
onEscape: e => {
|
||||||
|
e.preventDefault()
|
||||||
// On escape, focus the editor
|
// On escape, focus the editor
|
||||||
editor.chain().focus().run()
|
editor.chain().focus().run()
|
||||||
},
|
},
|
||||||
@ -43,8 +44,8 @@ export function InsertMenu({ editor }: { editor: Editor }) {
|
|||||||
onFocus={e => {
|
onFocus={e => {
|
||||||
// The ref we have is to the container, not the menu itself
|
// The ref we have is to the container, not the menu itself
|
||||||
if (containerRef.current === e.target?.parentNode) {
|
if (containerRef.current === e.target?.parentNode) {
|
||||||
// Focus the first button when the menu bar is focused
|
// Focus the first enabled button-like when the menu bar is focused
|
||||||
focusButton(containerRef.current?.querySelector('button'))
|
getFocusableElements()?.[0]?.focus()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
@ -2,7 +2,7 @@ import { Editor } from '@tiptap/core'
|
|||||||
import { useEditorState } from '@tiptap/react'
|
import { useEditorState } from '@tiptap/react'
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { useFocusMenubar } from './useFocusMenubar.js'
|
import { useMenubarNav } from './useMenubarNav.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the heading dropdown
|
* Handles the heading dropdown
|
||||||
@ -48,10 +48,24 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="node-type-dropdown__container" ref={menuRef}>
|
<div
|
||||||
|
className="node-type-dropdown__container"
|
||||||
|
ref={menuRef}
|
||||||
|
onKeyDown={e => {
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={e => {
|
||||||
setIsOpen(open => !open)
|
setIsOpen(open => !open)
|
||||||
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@ -257,7 +271,7 @@ export function MenuBar({ editor }: { editor: Editor }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useFocusMenubar({
|
useMenubarNav({
|
||||||
ref: containerRef,
|
ref: containerRef,
|
||||||
editor,
|
editor,
|
||||||
onKeydown: event => {
|
onKeydown: event => {
|
||||||
|
@ -2,7 +2,7 @@ import { Selection } from '@tiptap/pm/state'
|
|||||||
import { BubbleMenu, Editor, useEditorState } from '@tiptap/react'
|
import { BubbleMenu, Editor, useEditorState } from '@tiptap/react'
|
||||||
import React, { useRef } from '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.
|
* 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
|
// Handle arrow navigation within a menu bar container, and allow to escape to the editor
|
||||||
const { focusButton } = useFocusMenubar({
|
const { getFocusableElements } = useMenubarNav({
|
||||||
editor,
|
editor,
|
||||||
ref: containerRef,
|
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
|
// On escape, focus the editor & dismiss the menu by moving the selection to the end of the selection
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
@ -63,7 +64,7 @@ export function TextMenu({ editor }: { editor: Editor }) {
|
|||||||
// The ref we have is to the container, not the menu itself
|
// The ref we have is to the container, not the menu itself
|
||||||
if (containerRef.current === e.target?.parentNode) {
|
if (containerRef.current === e.target?.parentNode) {
|
||||||
// Focus the first button when the menu bar is focused
|
// Focus the first button when the menu bar is focused
|
||||||
focusButton(containerRef.current?.querySelector('button'))
|
getFocusableElements()?.[0]?.focus()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
@ -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<HTMLElement>;
|
|
||||||
/**
|
|
||||||
* 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<HTMLElement> }
|
|
||||||
) => 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 }
|
|
||||||
}
|
|
128
demos/src/Examples/Accessibility/React/useMenubarNav.ts
Normal file
128
demos/src/Examples/Accessibility/React/useMenubarNav.ts
Normal file
@ -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<HTMLElement>;
|
||||||
|
/**
|
||||||
|
* The editor instance
|
||||||
|
*/
|
||||||
|
editor: Editor;
|
||||||
|
/**
|
||||||
|
* Callback when the user presses the escape key
|
||||||
|
*/
|
||||||
|
onEscape?: (
|
||||||
|
event: KeyboardEvent,
|
||||||
|
ctx: { editor: Editor; ref: React.RefObject<HTMLElement> }
|
||||||
|
) => 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<HTMLElement> }
|
||||||
|
) => 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<HTMLElement>(
|
||||||
|
'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 }
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user