refactor: cleanup implementation

This commit is contained in:
Nick the Sick 2024-11-11 12:21:06 +01:00
parent 2766d6f975
commit ed89305e1a
No known key found for this signature in database
GPG Key ID: F575992F156E5BCC
5 changed files with 157 additions and 176 deletions

View File

@ -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}

View File

@ -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 => {

View File

@ -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}

View File

@ -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 }
}

View 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 }
}