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 { Editor, FloatingMenu, useEditorState } from '@tiptap/react'
import React, { useRef } from 'react' import React, { useRef } from 'react'
// import { useFocusMenubar } from './useFocusMenubar.js' import { useFocusMenubar } from './useFocusMenubar.js'
export function InsertMenu({ editor }: { editor: Editor }) { export function InsertMenu({ editor }: { editor: Editor }) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const editorState = useEditorState({ const { activeNodeType } = useEditorState({
editor, editor,
selector: ctx => { selector: ctx => {
const activeNode = ctx.editor.state.selection.$from.node(1)
return { return {
activeNodeType: activeNode?.type.name ?? 'paragraph',
} }
}, },
}) })
// // 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
// useFocusMenubar({ const { focusButton } = useFocusMenubar({
// editor, editor,
// ref: containerRef, ref: containerRef,
// onEscape: () => { onEscape: () => {
// // On escape, focus the editor & dismiss the menu by moving the selection to the end of the selection // On escape, focus the editor
// editor.chain().focus().command(({ tr }) => { editor.chain().focus().run()
// tr.setSelection(Selection.near(tr.selection.$to)) },
// return true })
// }).run()
// },
// })
return ( return (
<FloatingMenu <FloatingMenu
@ -33,16 +33,49 @@ export function InsertMenu({ editor }: { editor: Editor }) {
shouldShow={null} shouldShow={null}
aria-orientation="horizontal" aria-orientation="horizontal"
role="menubar" role="menubar"
aria-label="Insert Element menu"
className='floating-menu'
// Types are broken here, since we import jsx from vue-2 // Types are broken here, since we import jsx from vue-2
ref={containerRef as any} ref={containerRef as any}
// This is a raw HTML element, so we can't use onFocus onFocus={e => {
onfocus={() => { // The ref we have is to the container, not the menu itself
// Focus the first button when the menu bar is focused if (containerRef.current === e.target?.parentNode) {
containerRef.current?.querySelector('button')?.focus() // Focus the first button when the menu bar is focused
focusButton(containerRef.current?.querySelector('button'))
}
}} }}
tabIndex={0} 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> </FloatingMenu>
) )
} }

View File

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

View File

@ -15,7 +15,7 @@ export function NodeTypeDropdown({ editor }: { editor: Editor }) {
const activeNode = ctx.editor.state.selection.$from.node(1) const activeNode = ctx.editor.state.selection.$from.node(1)
return { 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} tabIndex={-1}
> >
Node Type: {activeNodeType} Node Type: {activeNodeType.slice(0, 1).toUpperCase() + activeNodeType.slice(1)}
</button> </button>
{isOpen && ( {isOpen && (
<div <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 // Handle arrow navigation within a menu bar container, and allow to escape to the editor
useFocusMenubar({ const { focusButton } = useFocusMenubar({
editor, editor,
ref: containerRef, ref: containerRef,
onEscape: () => { onEscape: () => {
@ -46,14 +46,18 @@ export function TextMenu({ editor }: { editor: Editor }) {
<BubbleMenu <BubbleMenu
editor={editor} editor={editor}
shouldShow={null} shouldShow={null}
aria-label="Text formatting menu"
aria-orientation="horizontal" aria-orientation="horizontal"
role="menubar" role="menubar"
className='bubble-menu'
// Types are broken here, since we import jsx from vue-2 // Types are broken here, since we import jsx from vue-2
ref={containerRef as any} ref={containerRef as any}
// This is a raw HTML element, so we can't use onFocus onFocus={e => {
onfocus={() => { // The ref we have is to the container, not the menu itself
// Focus the first button when the menu bar is focused if (containerRef.current === e.target?.parentNode) {
containerRef.current?.querySelector('button')?.focus() // Focus the first button when the menu bar is focused
focusButton(containerRef.current?.querySelector('button'))
}
}} }}
tabIndex={0} tabIndex={0}
> >

View File

@ -1,47 +1,41 @@
import "./styles.scss"; import './styles.scss'
import Placeholder from "@tiptap/extension-placeholder"; // import Placeholder from '@tiptap/extension-placeholder'
import { EditorContent, useEditor } from "@tiptap/react"; import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from "@tiptap/starter-kit"; import StarterKit from '@tiptap/starter-kit'
import React from "react"; import React from 'react'
import { TextMenu } from "./TextMenu"; import { InsertMenu } from './InsertMenu.jsx'
import { MenuBar } from "./MenuBar.jsx"; import { MenuBar } from './MenuBar.jsx'
import { InsertMenu } from "./InsertMenu"; import { TextMenu } from './TextMenu.jsx'
export default () => { export default () => {
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, 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: { editorProps: {
attributes: (state): Record<string, string> => { attributes: {
return { // Make sure the editor is announced as a rich text editor
// Make sure the editor is announced as a rich text editor 'aria-label': 'Rich Text Editor',
"aria-label": "Rich Text Editor", // editor accepts multiline input
// editor accepts multiline input 'aria-multiline': 'true',
"aria-multiline": "true", 'aria-readonly': 'false',
// dynamically set the aria-readonly attribute
"aria-readonly": editor?.isEditable ? "false" : "true",
};
}, },
}, },
}); })
if (!editor) {
return null;
}
return ( return (
<div role="application"> <div role="application" className="editor-application">
<MenuBar editor={editor} /> <MenuBar editor={editor} />
<EditorContent editor={editor} /> <EditorContent editor={editor} />
<TextMenu editor={editor} /> <TextMenu editor={editor} />
<InsertMenu editor={editor} /> <InsertMenu editor={editor} />
</div> </div>
); )
}; }

View File

@ -14,11 +14,70 @@
z-index: 1000; 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 */ /* Basic editor styles */
.tiptap { .tiptap {
:first-child { :first-child {
margin-top: 0; margin-top: 0;
} }
&:focus {
outline: 1px solid var(--purple);
outline-offset: 4px;
}
/* List styles */ /* List styles */
ul, ul,

View File

@ -1,5 +1,5 @@
import { Editor } from '@tiptap/core' 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 * Handle arrow navigation within a menu bar container, and allow to escape to the editor
@ -46,6 +46,62 @@ export function useFocusMenubar({
onKeydown, 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(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!containerRef.current) { if (!containerRef.current) {
@ -60,44 +116,25 @@ export function useFocusMenubar({
const elements = Array.from(containerRef.current.querySelectorAll('button')) const elements = Array.from(containerRef.current.querySelectorAll('button'))
const isFocusedOnButton = elements.includes(event.target as HTMLButtonElement) const isFocusedOnButton = elements.includes(event.target as HTMLButtonElement)
// Allow to escape to the editor
if (isFocusedOnButton || event.target === containerRef.current) { if (isFocusedOnButton || event.target === containerRef.current) {
// Allow to escape to the editor
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault() event.preventDefault()
callbacks.current.onEscape(editor) callbacks.current.onEscape(editor)
return return true
} }
}
if (isFocusedOnButton) {
// Handle arrow navigation within the menu bar // Handle arrow navigation within the menu bar
if (event.key === 'ArrowRight') { if (event.key === 'ArrowRight') {
const index = elements.indexOf(event.target as HTMLButtonElement) if (focusNextButton(event.target as HTMLButtonElement)) {
event.preventDefault()
// Find the next enabled button return true
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 (event.key === 'ArrowLeft') { if (event.key === 'ArrowLeft') {
const index = elements.indexOf(event.target as HTMLButtonElement) if (focusPreviousButton(event.target as HTMLButtonElement)) {
event.preventDefault()
// Find the previous enabled button return true
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
}
} }
} }
} }
@ -108,5 +145,7 @@ export function useFocusMenubar({
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown) 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 isRootDepth = $anchor.depth === 1
const isEmptyTextBlock = $anchor.parent.isTextblock && !$anchor.parent.type.spec.code && !$anchor.parent.textContent const isEmptyTextBlock = $anchor.parent.isTextblock && !$anchor.parent.type.spec.code && !$anchor.parent.textContent
console.log('should ran')
if ( if (
!view.hasFocus() !view.hasFocus()
|| !empty || !empty
@ -94,7 +93,6 @@ export class FloatingMenuView {
) { ) {
return false return false
} }
console.log(true)
return true return true
} }
@ -173,7 +171,6 @@ export class FloatingMenuView {
...options, ...options,
} }
console.log('should show', shouldShow)
if (shouldShow) { if (shouldShow) {
this.shouldShow = shouldShow this.shouldShow = shouldShow
} }

View File

@ -11,74 +11,84 @@ export type BubbleMenuProps = Omit<
'element' | 'editor' 'element' | 'editor'
> & { > & {
editor: BubbleMenuPluginProps['editor'] | null; editor: BubbleMenuPluginProps['editor'] | null;
className?: string;
children: React.ReactNode;
updateDelay?: number; updateDelay?: number;
resizeDelay?: number; resizeDelay?: number;
options?: BubbleMenuPluginProps['options']; options?: BubbleMenuPluginProps['options'];
} & Partial<Omit<HTMLDivElement, 'children' | 'class'>>; } & React.HTMLAttributes<HTMLDivElement>;
export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(({ export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
pluginKey = 'bubbleMenu', (
editor, {
updateDelay, pluginKey = 'bubbleMenu',
resizeDelay, editor,
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({
updateDelay, updateDelay,
resizeDelay, resizeDelay,
editor: attachToEditor, shouldShow = null,
element: bubbleMenuElement,
pluginKey,
shouldShow,
options, options,
}) children,
...restProps
},
ref,
) => {
const menuEl = useRef(document.createElement('div'))
attachToEditor.registerPlugin(plugin) if (typeof ref === 'function') {
ref(menuEl.current)
return () => { } else if (ref) {
attachToEditor.unregisterPlugin(pluginKey) ref.current = menuEl.current
window.requestAnimationFrame(() => {
if (bubbleMenuElement.parentNode) {
bubbleMenuElement.parentNode.removeChild(bubbleMenuElement)
}
})
} }
// 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' 'element' | 'editor'
> & { > & {
editor: FloatingMenuPluginProps['editor'] | null; editor: FloatingMenuPluginProps['editor'] | null;
className?: string;
children: React.ReactNode;
options?: FloatingMenuPluginProps['options']; options?: FloatingMenuPluginProps['options'];
} & Partial<Omit<HTMLDivElement, 'children' | 'class'>>; } & React.HTMLAttributes<HTMLDivElement>;
export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(({ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(({
pluginKey = 'floatingMenu', pluginKey = 'floatingMenu',
@ -24,7 +22,7 @@ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
children, children,
...restProps ...restProps
}, ref) => { }, ref) => {
const menuEl = useRef(Object.assign(document.createElement('div'), restProps)) const menuEl = useRef(document.createElement('div'))
if (typeof ref === 'function') { if (typeof ref === 'function') {
ref(menuEl.current) ref(menuEl.current)
@ -74,5 +72,12 @@ export const FloatingMenu = React.forwardRef<HTMLDivElement, FloatingMenuProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, currentEditor]) }, [editor, currentEditor])
return createPortal(<>{children}</>, menuEl.current) return createPortal(
<div
{...restProps}
>
{children}
</div>,
menuEl.current,
)
}) })