mirror of
https://github.com/ueberdosis/tiptap.git
synced 2024-11-27 14:59:27 +08:00
refactor: cleanup implementation
Some checks failed
build / lint (20) (push) Has been cancelled
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Has been cancelled
Publish / Release (20) (push) Has been cancelled
build / build (20) (push) Has been cancelled
Some checks failed
build / lint (20) (push) Has been cancelled
build / test (20, map[name:Demos/Examples spec:./demos/src/Examples/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Experiments spec:./demos/src/Experiments/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Extensions spec:./demos/src/Extensions/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideContent spec:./demos/src/GuideContent/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/GuideGettingStarted spec:./demos/src/GuideGettingStarted/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Marks spec:./demos/src/Marks/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Demos/Nodes spec:./demos/src/Nodes/**/*.spec.{js,ts}]) (push) Has been cancelled
build / test (20, map[name:Integration spec:./tests/cypress/integration/**/*.spec.{js,ts}]) (push) Has been cancelled
Publish / Release (20) (push) Has been cancelled
build / build (20) (push) Has been cancelled
This commit is contained in:
parent
0f5b8da49b
commit
a018b63593
@ -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}
|
||||
|
@ -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 (
|
||||
<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
|
||||
onClick={() => {
|
||||
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 => {
|
||||
|
@ -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}
|
||||
|
@ -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