feat: make the example much better

This commit is contained in:
Nick the Sick 2024-11-07 14:53:39 +01:00
parent bd6c257bf6
commit 01d6dc89a8
No known key found for this signature in database
GPG Key ID: F575992F156E5BCC
10 changed files with 311 additions and 183 deletions

View File

@ -1,31 +1,31 @@
import { Editor, FloatingMenu, useEditorState } from '@tiptap/react'
import React, { useRef } from 'react'
// import { useFocusMenubar } from './useFocusMenubar.js'
import { useFocusMenubar } from './useFocusMenubar.js'
export function InsertMenu({ editor }: { editor: Editor }) {
const containerRef = useRef<HTMLDivElement>(null)
const editorState = useEditorState({
const { activeNodeType } = useEditorState({
editor,
selector: ctx => {
const activeNode = ctx.editor.state.selection.$from.node(1)
return {
activeNodeType: activeNode?.type.name ?? 'paragraph',
}
},
})
// // Handle arrow navigation within a menu bar container, and allow to escape to the editor
// useFocusMenubar({
// editor,
// ref: containerRef,
// onEscape: () => {
// // On escape, focus the editor & dismiss the menu by moving the selection to the end of the selection
// editor.chain().focus().command(({ tr }) => {
// tr.setSelection(Selection.near(tr.selection.$to))
// return true
// }).run()
// },
// })
// Handle arrow navigation within a menu bar container, and allow to escape to the editor
const { focusButton } = useFocusMenubar({
editor,
ref: containerRef,
onEscape: () => {
// On escape, focus the editor
editor.chain().focus().run()
},
})
return (
<FloatingMenu
@ -33,16 +33,49 @@ export function InsertMenu({ editor }: { editor: Editor }) {
shouldShow={null}
aria-orientation="horizontal"
role="menubar"
aria-label="Insert Element menu"
className='floating-menu'
// Types are broken here, since we import jsx from vue-2
ref={containerRef as any}
// This is a raw HTML element, so we can't use onFocus
onfocus={() => {
// Focus the first button when the menu bar is focused
containerRef.current?.querySelector('button')?.focus()
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'))
}
}}
tabIndex={0}
>
TST
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={activeNodeType === 'bulletList' ? 'is-active' : ''}
aria-label="Bullet List"
tabIndex={-1}
>
Bullet list
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={activeNodeType === 'orderedList' ? 'is-active' : ''}
aria-label="Ordered List"
tabIndex={-1}
>
Ordered List
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
aria-label="Horizontal rule"
tabIndex={-1}
>
Horizontal rule
</button>
<button
onClick={() => editor.chain().focus().setHardBreak().run()}
aria-label="Hard break"
tabIndex={-1}
>
Hard break
</button>
</FloatingMenu>
)
}

View File

@ -1,53 +1,39 @@
import { Editor } from "@tiptap/core";
import React, { useRef } from "react";
import { Editor } from '@tiptap/core'
import React, { useRef } from 'react'
import { NodeTypeDropdown } from "./NodeTypeDropdown.js";
import { useFocusMenubar } from "./useFocusMenubar.js";
import { NodeTypeDropdown } from './NodeTypeDropdown.js'
import { useFocusMenubar } from './useFocusMenubar.js'
/**
* An accessible menu bar for the editor
*/
export const MenuBar = ({ editor }: { editor: Editor }) => {
const containerRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null)
useFocusMenubar({
ref: containerRef,
editor,
onKeydown: (event) => {
onKeydown: event => {
// Handle focus on alt + f10
if (event.altKey && event.key === "F10") {
event.preventDefault();
containerRef.current?.querySelector("button")?.focus();
return;
if (event.altKey && event.key === 'F10') {
event.preventDefault()
containerRef.current?.querySelector('button')?.focus()
}
},
});
})
if (!editor) {
return null;
return null
}
return (
<div className="control-group" role="toolbar" aria-orientation="horizontal" ref={containerRef}>
<div className="button-group">
<NodeTypeDropdown editor={editor} />
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
tabIndex={-1}
aria-label="Horizontal rule"
>
Horizontal rule
</button>
<button
onClick={() => editor.chain().focus().setHardBreak().run()}
tabIndex={-1}
aria-label="Hard break"
>
Hard break
</button>
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo()
.run()}
tabIndex={-1}
aria-label="Undo"
>
@ -55,7 +41,8 @@ export const MenuBar = ({ editor }: { editor: Editor }) => {
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo()
.run()}
tabIndex={-1}
aria-label="Redo"
>
@ -63,5 +50,5 @@ export const MenuBar = ({ editor }: { editor: Editor }) => {
</button>
</div>
</div>
);
};
)
}

View File

@ -15,7 +15,7 @@ export function NodeTypeDropdown({ editor }: { editor: Editor }) {
const activeNode = ctx.editor.state.selection.$from.node(1)
return {
activeNodeType: activeNode.type.name.slice(0, 1).toUpperCase() + activeNode.type.name.slice(1) || 'Paragraph',
activeNodeType: activeNode?.type.name ?? 'paragraph',
}
},
})
@ -53,7 +53,7 @@ export function NodeTypeDropdown({ editor }: { editor: Editor }) {
}`}
tabIndex={-1}
>
Node Type: {activeNodeType}
Node Type: {activeNodeType.slice(0, 1).toUpperCase() + activeNodeType.slice(1)}
</button>
{isOpen && (
<div

View File

@ -30,7 +30,7 @@ export function TextMenu({ editor }: { editor: Editor }) {
})
// Handle arrow navigation within a menu bar container, and allow to escape to the editor
useFocusMenubar({
const { focusButton } = useFocusMenubar({
editor,
ref: containerRef,
onEscape: () => {
@ -46,14 +46,18 @@ export function TextMenu({ editor }: { editor: Editor }) {
<BubbleMenu
editor={editor}
shouldShow={null}
aria-label="Text formatting menu"
aria-orientation="horizontal"
role="menubar"
className='bubble-menu'
// Types are broken here, since we import jsx from vue-2
ref={containerRef as any}
// This is a raw HTML element, so we can't use onFocus
onfocus={() => {
// Focus the first button when the menu bar is focused
containerRef.current?.querySelector('button')?.focus()
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'))
}
}}
tabIndex={0}
>

View File

@ -1,47 +1,41 @@
import "./styles.scss";
import './styles.scss'
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import React from "react";
// import Placeholder from '@tiptap/extension-placeholder'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React from 'react'
import { TextMenu } from "./TextMenu";
import { MenuBar } from "./MenuBar.jsx";
import { InsertMenu } from "./InsertMenu";
import { InsertMenu } from './InsertMenu.jsx'
import { MenuBar } from './MenuBar.jsx'
import { TextMenu } from './TextMenu.jsx'
export default () => {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({
placeholder: "Use Alt + F10 to focus the menu bar",
}),
],
immediatelyRender: false,
content: `
<h2>Accessibility Demo</h2>
<p>Tab into the demo & navigate around with only your keyboard</p>
<p>Use <code>Alt + F10</code> to focus the menu bar</p>
`,
editorProps: {
attributes: (state): Record<string, string> => {
return {
// Make sure the editor is announced as a rich text editor
"aria-label": "Rich Text Editor",
// editor accepts multiline input
"aria-multiline": "true",
// dynamically set the aria-readonly attribute
"aria-readonly": editor?.isEditable ? "false" : "true",
};
attributes: {
// Make sure the editor is announced as a rich text editor
'aria-label': 'Rich Text Editor',
// editor accepts multiline input
'aria-multiline': 'true',
'aria-readonly': 'false',
},
},
});
if (!editor) {
return null;
}
})
return (
<div role="application">
<div role="application" className="editor-application">
<MenuBar editor={editor} />
<EditorContent editor={editor} />
<TextMenu editor={editor} />
<InsertMenu editor={editor} />
</div>
);
};
)
}

View File

@ -14,11 +14,70 @@
z-index: 1000;
}
/* Bubble menu */
.bubble-menu {
background-color: var(--white);
border: 1px solid var(--gray-1);
border-radius: 0.7rem;
box-shadow: var(--shadow);
display: flex;
padding: 0.2rem;
button {
background-color: unset;
&:hover {
background-color: var(--gray-3);
}
&.is-active {
background-color: var(--purple);
&:hover {
background-color: var(--purple-contrast);
}
}
}
}
/* Floating menu */
.floating-menu {
display: flex;
background-color: var(--gray-3);
padding: 0.1rem;
border-radius: 0.5rem;
button {
background-color: unset;
padding: 0.275rem 0.425rem;
border-radius: 0.3rem;
&:hover {
background-color: var(--gray-3);
}
&.is-active {
background-color: var(--white);
color: var(--purple);
&:hover {
color: var(--purple-contrast);
}
}
}
}
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
&:focus {
outline: 1px solid var(--purple);
outline-offset: 4px;
}
/* List styles */
ul,

View File

@ -1,5 +1,5 @@
import { Editor } from '@tiptap/core'
import React, { useEffect, useRef } from 'react'
import React, { useCallback, useEffect, useRef } from 'react'
/**
* Handle arrow navigation within a menu bar container, and allow to escape to the editor
@ -46,6 +46,62 @@ export function useFocusMenubar({
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) {
@ -60,44 +116,25 @@ export function useFocusMenubar({
const elements = Array.from(containerRef.current.querySelectorAll('button'))
const isFocusedOnButton = elements.includes(event.target as HTMLButtonElement)
// Allow to escape to the editor
if (isFocusedOnButton || event.target === containerRef.current) {
// Allow to escape to the editor
if (event.key === 'Escape') {
event.preventDefault()
callbacks.current.onEscape(editor)
return
return true
}
}
if (isFocusedOnButton) {
// Handle arrow navigation within the menu bar
if (event.key === 'ArrowRight') {
const index = elements.indexOf(event.target as HTMLButtonElement)
// Find the next enabled button
for (let i = index + 1; i <= elements.length; i += 1) {
if (!elements[i % elements.length].disabled) {
event.preventDefault()
elements[i % elements.length].focus()
return
}
if (focusNextButton(event.target as HTMLButtonElement)) {
event.preventDefault()
return true
}
}
if (event.key === 'ArrowLeft') {
const index = elements.indexOf(event.target as HTMLButtonElement)
// 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) {
event.preventDefault()
elements[i].focus()
return
}
if (focusPreviousButton(event.target as HTMLButtonElement)) {
event.preventDefault()
return true
}
}
}
@ -108,5 +145,7 @@ export function useFocusMenubar({
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [containerRef, editor])
}, [containerRef, editor, focusNextButton, focusPreviousButton])
return { focusNextButton, focusPreviousButton, focusButton }
}

View File

@ -84,7 +84,6 @@ export class FloatingMenuView {
const isRootDepth = $anchor.depth === 1
const isEmptyTextBlock = $anchor.parent.isTextblock && !$anchor.parent.type.spec.code && !$anchor.parent.textContent
console.log('should ran')
if (
!view.hasFocus()
|| !empty
@ -94,7 +93,6 @@ export class FloatingMenuView {
) {
return false
}
console.log(true)
return true
}
@ -173,7 +171,6 @@ export class FloatingMenuView {
...options,
}
console.log('should show', shouldShow)
if (shouldShow) {
this.shouldShow = shouldShow
}

View File

@ -11,74 +11,84 @@ export type BubbleMenuProps = Omit<
'element' | 'editor'
> & {
editor: BubbleMenuPluginProps['editor'] | null;
className?: string;
children: React.ReactNode;
updateDelay?: number;
resizeDelay?: number;
options?: BubbleMenuPluginProps['options'];
} & Partial<Omit<HTMLDivElement, 'children' | 'class'>>;
} & React.HTMLAttributes<HTMLDivElement>;
export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(({
pluginKey = 'bubbleMenu',
editor,
updateDelay,
resizeDelay,
shouldShow = null,
options,
children,
...restProps
}, ref) => {
const menuEl = useRef(Object.assign(document.createElement('div'), restProps))
if (typeof ref === 'function') {
ref(menuEl.current)
} else if (ref) {
ref.current = menuEl.current
}
const { editor: currentEditor } = useCurrentEditor()
useEffect(() => {
const bubbleMenuElement = menuEl.current
bubbleMenuElement.style.visibility = 'hidden'
bubbleMenuElement.style.position = 'absolute'
if (editor?.isDestroyed || currentEditor?.isDestroyed) {
return
}
const attachToEditor = editor || currentEditor
if (!attachToEditor) {
console.warn(
'BubbleMenu component is not rendered inside of an editor component or does not have editor prop.',
)
return
}
const plugin = BubbleMenuPlugin({
export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
(
{
pluginKey = 'bubbleMenu',
editor,
updateDelay,
resizeDelay,
editor: attachToEditor,
element: bubbleMenuElement,
pluginKey,
shouldShow,
shouldShow = null,
options,
})
children,
...restProps
},
ref,
) => {
const menuEl = useRef(document.createElement('div'))
attachToEditor.registerPlugin(plugin)
return () => {
attachToEditor.unregisterPlugin(pluginKey)
window.requestAnimationFrame(() => {
if (bubbleMenuElement.parentNode) {
bubbleMenuElement.parentNode.removeChild(bubbleMenuElement)
}
})
if (typeof ref === 'function') {
ref(menuEl.current)
} else if (ref) {
ref.current = menuEl.current
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, currentEditor])
return createPortal(<>{children}</>, menuEl.current)
})
const { editor: currentEditor } = useCurrentEditor()
useEffect(() => {
const bubbleMenuElement = menuEl.current
bubbleMenuElement.style.visibility = 'hidden'
bubbleMenuElement.style.position = 'absolute'
if (editor?.isDestroyed || currentEditor?.isDestroyed) {
return
}
const attachToEditor = editor || currentEditor
if (!attachToEditor) {
console.warn(
'BubbleMenu component is not rendered inside of an editor component or does not have editor prop.',
)
return
}
const plugin = BubbleMenuPlugin({
updateDelay,
resizeDelay,
editor: attachToEditor,
element: bubbleMenuElement,
pluginKey,
shouldShow,
options,
})
attachToEditor.registerPlugin(plugin)
return () => {
attachToEditor.unregisterPlugin(pluginKey)
window.requestAnimationFrame(() => {
if (bubbleMenuElement.parentNode) {
bubbleMenuElement.parentNode.removeChild(bubbleMenuElement)
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, currentEditor])
return createPortal(
<div
{...restProps}
>
{children}
</div>,
menuEl.current,
)
},
)

View File

@ -11,10 +11,8 @@ export type FloatingMenuProps = Omit<
'element' | 'editor'
> & {
editor: FloatingMenuPluginProps['editor'] | null;
className?: string;
children: React.ReactNode;
options?: FloatingMenuPluginProps['options'];
} & Partial<Omit<HTMLDivElement, 'children' | 'class'>>;
} & React.HTMLAttributes<HTMLDivElement>;
export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(({
pluginKey = 'floatingMenu',
@ -24,7 +22,7 @@ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
children,
...restProps
}, ref) => {
const menuEl = useRef(Object.assign(document.createElement('div'), restProps))
const menuEl = useRef(document.createElement('div'))
if (typeof ref === 'function') {
ref(menuEl.current)
@ -74,5 +72,12 @@ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, currentEditor])
return createPortal(<>{children}</>, menuEl.current)
return createPortal(
<div
{...restProps}
>
{children}
</div>,
menuEl.current,
)
})