From 70bc31b5046313d728103f7b218f9ec11adb7036 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 6 Nov 2024 18:58:00 +0100 Subject: [PATCH 01/11] feat(demo): add accessibility demo --- .../Accessibility/React/InsertMenu.tsx | 48 +++++ .../Examples/Accessibility/React/MenuBar.tsx | 67 +++++++ .../Accessibility/React/NodeTypeDropdown.tsx | 165 ++++++++++++++++++ .../Examples/Accessibility/React/TextMenu.tsx | 106 +++++++++++ .../Examples/Accessibility/React/index.html | 0 .../Accessibility/React/index.spec.js | 102 +++++++++++ .../Examples/Accessibility/React/index.tsx | 47 +++++ .../Examples/Accessibility/React/styles.scss | 125 +++++++++++++ .../Accessibility/React/useFocusMenubar.ts | 112 ++++++++++++ .../src/bubble-menu-plugin.ts | 12 +- .../src/floating-menu-plugin.ts | 9 +- packages/react/src/BubbleMenu.tsx | 78 +++++---- packages/react/src/FloatingMenu.tsx | 95 +++++----- 13 files changed, 877 insertions(+), 89 deletions(-) create mode 100644 demos/src/Examples/Accessibility/React/InsertMenu.tsx create mode 100644 demos/src/Examples/Accessibility/React/MenuBar.tsx create mode 100644 demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx create mode 100644 demos/src/Examples/Accessibility/React/TextMenu.tsx create mode 100644 demos/src/Examples/Accessibility/React/index.html create mode 100644 demos/src/Examples/Accessibility/React/index.spec.js create mode 100644 demos/src/Examples/Accessibility/React/index.tsx create mode 100644 demos/src/Examples/Accessibility/React/styles.scss create mode 100644 demos/src/Examples/Accessibility/React/useFocusMenubar.ts diff --git a/demos/src/Examples/Accessibility/React/InsertMenu.tsx b/demos/src/Examples/Accessibility/React/InsertMenu.tsx new file mode 100644 index 00000000000..9842e8ebb10 --- /dev/null +++ b/demos/src/Examples/Accessibility/React/InsertMenu.tsx @@ -0,0 +1,48 @@ +import { Editor, FloatingMenu, useEditorState } from '@tiptap/react' +import React, { useRef } from 'react' + +// import { useFocusMenubar } from './useFocusMenubar.js' + +export function InsertMenu({ editor }: { editor: Editor }) { + const containerRef = useRef(null) + + const editorState = useEditorState({ + editor, + selector: ctx => { + return { + } + }, + }) + + // // 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() + // }, + // }) + + return ( + { + // Focus the first button when the menu bar is focused + containerRef.current?.querySelector('button')?.focus() + }} + tabIndex={0} + > + TST + + ) +} diff --git a/demos/src/Examples/Accessibility/React/MenuBar.tsx b/demos/src/Examples/Accessibility/React/MenuBar.tsx new file mode 100644 index 00000000000..cae4b26eaeb --- /dev/null +++ b/demos/src/Examples/Accessibility/React/MenuBar.tsx @@ -0,0 +1,67 @@ +import { Editor } from "@tiptap/core"; +import React, { useRef } from "react"; + +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(null); + + useFocusMenubar({ + ref: containerRef, + editor, + onKeydown: (event) => { + // Handle focus on alt + f10 + if (event.altKey && event.key === "F10") { + event.preventDefault(); + containerRef.current?.querySelector("button")?.focus(); + return; + } + }, + }); + + if (!editor) { + return null; + } + + return ( +
+
+ + + + + +
+
+ ); +}; diff --git a/demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx b/demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx new file mode 100644 index 00000000000..c3e6eeb4873 --- /dev/null +++ b/demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx @@ -0,0 +1,165 @@ +import { Editor } from '@tiptap/core' +import { useEditorState } from '@tiptap/react' +import React, { useEffect, useRef, useState } from 'react' + +/** + * Handles the heading dropdown + */ +export function NodeTypeDropdown({ editor }: { editor: Editor }) { + const dropdownRef = useRef(null) + const menuRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const { activeNodeType } = useEditorState({ + editor, + selector: ctx => { + const activeNode = ctx.editor.state.selection.$from.node(1) + + return { + activeNodeType: activeNode.type.name.slice(0, 1).toUpperCase() + activeNode.type.name.slice(1) || 'Paragraph', + } + }, + }) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + document.addEventListener('click', handleClickOutside) + return () => { + document.removeEventListener('click', handleClickOutside) + } + }, []) + + return ( +
+ + {isOpen && ( + + )} +
+ ) +} diff --git a/demos/src/Examples/Accessibility/React/TextMenu.tsx b/demos/src/Examples/Accessibility/React/TextMenu.tsx new file mode 100644 index 00000000000..04189b65d41 --- /dev/null +++ b/demos/src/Examples/Accessibility/React/TextMenu.tsx @@ -0,0 +1,106 @@ +import { Selection } from '@tiptap/pm/state' +import { BubbleMenu, Editor, useEditorState } from '@tiptap/react' +import React, { useRef } from 'react' + +import { useFocusMenubar } from './useFocusMenubar.js' + +export function TextMenu({ editor }: { editor: Editor }) { + const containerRef = useRef(null) + + const editorState = useEditorState({ + editor, + selector: ctx => { + return { + isBold: ctx.editor.isActive('bold'), + canBold: ctx.editor.can().chain().focus().toggleBold() + .run(), + isItalic: ctx.editor.isActive('italic'), + canItalic: ctx.editor.can().chain().focus().toggleItalic() + .run(), + isStrike: ctx.editor.isActive('strike'), + canStrike: ctx.editor.can().chain().focus().toggleStrike() + .run(), + isCode: ctx.editor.isActive('code'), + canCode: ctx.editor.can().chain().focus().toggleCode() + .run(), + canClearMarks: ctx.editor.can().chain().focus().unsetAllMarks() + .run(), + } + }, + }) + + // 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() + }, + }) + + return ( + { + // Focus the first button when the menu bar is focused + containerRef.current?.querySelector('button')?.focus() + }} + tabIndex={0} + > + + + + + + + ) +} diff --git a/demos/src/Examples/Accessibility/React/index.html b/demos/src/Examples/Accessibility/React/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/demos/src/Examples/Accessibility/React/index.spec.js b/demos/src/Examples/Accessibility/React/index.spec.js new file mode 100644 index 00000000000..de99ed56e97 --- /dev/null +++ b/demos/src/Examples/Accessibility/React/index.spec.js @@ -0,0 +1,102 @@ +context('/src/Examples/AutolinkValidation/React/', () => { + before(() => { + cy.visit('/src/Examples/AutolinkValidation/React/') + }) + + beforeEach(() => { + cy.get('.tiptap').type('{selectall}{backspace}') + }) + + const validLinks = [ + // [rawTextInput, textThatShouldBeLinked] + ['https://tiptap.dev ', 'https://tiptap.dev'], + ['http://tiptap.dev ', 'http://tiptap.dev'], + ['https://www.tiptap.dev/ ', 'https://www.tiptap.dev/'], + ['http://www.tiptap.dev/ ', 'http://www.tiptap.dev/'], + ['[http://www.example.com/] ', 'http://www.example.com/'], + ['(http://www.example.com/) ', 'http://www.example.com/'], + ] + + const invalidLinks = [ + 'tiptap.dev', + 'www.tiptap.dev', + // If you don't type a space, don't autolink + 'https://tiptap.dev', + ] + + validLinks.forEach(([rawTextInput, textThatShouldBeLinked]) => { + it(`should autolink ${rawTextInput}`, () => { + cy.get('.tiptap').type(rawTextInput) + cy.get('.tiptap a').contains(textThatShouldBeLinked) + }) + }) + + invalidLinks.forEach(rawTextInput => { + it(`should not autolink ${rawTextInput}`, () => { + cy.get('.tiptap').type(`{selectall}{backspace}${rawTextInput}`) + cy.get('.tiptap a').should('not.exist') + }) + }) + + it('should not relink unset links after entering second link', () => { + cy.get('.tiptap').type('https://tiptap.dev {home}') + cy.get('.tiptap').should('have.text', 'https://tiptap.dev ') + cy.get('[data-testid=unsetLink]').click() + cy.get('.tiptap') + .find('a') + .should('have.length', 0) + cy.get('.tiptap').type('{end}http://www.example.com/ ') + cy.get('.tiptap') + .find('a') + .should('have.length', 1) + .should('have.attr', 'href', 'http://www.example.com/') + }) + + it('should not relink unset links after hitting next paragraph', () => { + cy.get('.tiptap').type('https://tiptap.dev {home}') + cy.get('.tiptap').should('have.text', 'https://tiptap.dev ') + cy.get('[data-testid=unsetLink]').click() + cy.get('.tiptap') + .find('a') + .should('have.length', 0) + cy.get('.tiptap').type('{end}typing other text should prevent the link from relinking when hitting enter{enter}') + cy.get('.tiptap') + .find('a') + .should('have.length', 0) + }) + + it('should not relink unset links after modifying', () => { + cy.get('.tiptap').type('https://tiptap.dev {home}') + cy.get('.tiptap').should('have.text', 'https://tiptap.dev ') + cy.get('[data-testid=unsetLink]').click() + cy.get('.tiptap') + .find('a') + .should('have.length', 0) + cy.get('.tiptap') + .type('{home}') + .type('{rightArrow}'.repeat('https://'.length)) + .type('blah') + cy.get('.tiptap').should('have.text', 'https://blahtiptap.dev ') + cy.get('.tiptap') + .find('a') + .should('have.length', 0) + }) + + it('should autolink after hitting enter (new paragraph)', () => { + cy.get('.tiptap').type('https://tiptap.dev{enter}') + cy.get('.tiptap').should('have.text', 'https://tiptap.dev') + cy.get('.tiptap') + .find('a') + .should('have.length', 1) + .should('have.attr', 'href', 'https://tiptap.dev') + }) + + it('should autolink after hitting shift-enter (hardbreak)', () => { + cy.get('.tiptap').type('https://tiptap.dev{shift+enter}') + cy.get('.tiptap').should('have.text', 'https://tiptap.dev') + cy.get('.tiptap') + .find('a') + .should('have.length', 1) + .should('have.attr', 'href', 'https://tiptap.dev') + }) +}) diff --git a/demos/src/Examples/Accessibility/React/index.tsx b/demos/src/Examples/Accessibility/React/index.tsx new file mode 100644 index 00000000000..c7ba878799d --- /dev/null +++ b/demos/src/Examples/Accessibility/React/index.tsx @@ -0,0 +1,47 @@ +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 { TextMenu } from "./TextMenu"; +import { MenuBar } from "./MenuBar.jsx"; +import { InsertMenu } from "./InsertMenu"; + +export default () => { + const editor = useEditor({ + extensions: [ + StarterKit, + Placeholder.configure({ + placeholder: "Use Alt + F10 to focus the menu bar", + }), + ], + immediatelyRender: false, + editorProps: { + attributes: (state): Record => { + 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", + }; + }, + }, + }); + + if (!editor) { + return null; + } + + return ( +
+ + + + +
+ ); +}; diff --git a/demos/src/Examples/Accessibility/React/styles.scss b/demos/src/Examples/Accessibility/React/styles.scss new file mode 100644 index 00000000000..468746e302a --- /dev/null +++ b/demos/src/Examples/Accessibility/React/styles.scss @@ -0,0 +1,125 @@ +.node-type-dropdown__container { + position: relative; +} + +.node-type-dropdown__items { + display: flex; + flex-direction: column; + position: absolute; + left: 0; + right: 0; + gap: 0.25rem; + padding: 0.25rem 0; + background-color: var(--gray-1); + z-index: 1000; +} + +/* Basic editor styles */ +.tiptap { + :first-child { + margin-top: 0; + } + + /* List styles */ + ul, + ol { + padding: 0 1rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; + + li p { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + } + + /* Heading styles */ + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + margin-top: 2.5rem; + text-wrap: pretty; + } + + h1, + h2 { + margin-top: 3.5rem; + margin-bottom: 1.5rem; + } + + h1 { + font-size: 1.4rem; + } + + h2 { + font-size: 1.2rem; + } + + h3 { + font-size: 1.1rem; + } + + h4, + h5, + h6 { + font-size: 1rem; + } + + /* Code and preformatted text styles */ + code { + background-color: var(--purple-light); + border-radius: 0.4rem; + color: var(--black); + font-size: 0.85rem; + padding: 0.25em 0.3em; + } + + pre { + background: var(--black); + border-radius: 0.5rem; + color: var(--white); + font-family: 'JetBrainsMono', monospace; + margin: 1.5rem 0; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + } + + blockquote { + border-left: 3px solid var(--gray-3); + margin: 1.5rem 0; + padding-left: 1rem; + } + + hr { + border: none; + border-top: 1px solid var(--gray-2); + margin: 2rem 0; + } + + /* Link styles */ + a { + color: var(--purple); + cursor: pointer; + + &:hover { + color: var(--purple-contrast); + } + } +} + +.is-empty::before { + color: var(--gray-4); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} diff --git a/demos/src/Examples/Accessibility/React/useFocusMenubar.ts b/demos/src/Examples/Accessibility/React/useFocusMenubar.ts new file mode 100644 index 00000000000..e905a08bb24 --- /dev/null +++ b/demos/src/Examples/Accessibility/React/useFocusMenubar.ts @@ -0,0 +1,112 @@ +import { Editor } from '@tiptap/core' +import React, { 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; + /** + * 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 } + ) => void; +}) { + const callbacks = useRef({ + onEscape, + onKeydown, + }) + + // Always take the latest callback + callbacks.current = { + onEscape, + onKeydown, + } + + 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) + + // Allow to escape to the editor + if (isFocusedOnButton || event.target === containerRef.current) { + if (event.key === 'Escape') { + event.preventDefault() + callbacks.current.onEscape(editor) + return + } + } + + 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 (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 + } + } + } + } + } + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [containerRef, editor]) +} diff --git a/packages/extension-bubble-menu/src/bubble-menu-plugin.ts b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts index cede4869765..c269aa9b0aa 100644 --- a/packages/extension-bubble-menu/src/bubble-menu-plugin.ts +++ b/packages/extension-bubble-menu/src/bubble-menu-plugin.ts @@ -10,7 +10,9 @@ import { import { Editor, isTextSelection, posToDOMRect, } from '@tiptap/core' -import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state' +import { + EditorState, Plugin, PluginKey, PluginView, +} from '@tiptap/pm/state' import { EditorView } from '@tiptap/pm/view' export interface BubbleMenuPluginProps { @@ -86,7 +88,7 @@ export type BubbleMenuViewProps = BubbleMenuPluginProps & { view: EditorView } -export class BubbleMenuView { +export class BubbleMenuView implements PluginView { public editor: Editor public element: HTMLElement @@ -358,14 +360,14 @@ export class BubbleMenuView { show() { this.element.style.visibility = 'visible' this.element.style.opacity = '1' - // attach from body - document.body.appendChild(this.element) + // attach to editor's parent element + this.view.dom.parentElement?.appendChild(this.element) } hide() { this.element.style.visibility = 'hidden' this.element.style.opacity = '0' - // remove from body + // remove from the parent element this.element.remove() } diff --git a/packages/extension-floating-menu/src/floating-menu-plugin.ts b/packages/extension-floating-menu/src/floating-menu-plugin.ts index f2ab36af9cf..8dc0f709ed1 100644 --- a/packages/extension-floating-menu/src/floating-menu-plugin.ts +++ b/packages/extension-floating-menu/src/floating-menu-plugin.ts @@ -84,6 +84,7 @@ 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 @@ -93,6 +94,7 @@ export class FloatingMenuView { ) { return false } + console.log(true) return true } @@ -171,6 +173,7 @@ export class FloatingMenuView { ...options, } + console.log('should show', shouldShow) if (shouldShow) { this.shouldShow = shouldShow } @@ -275,14 +278,14 @@ export class FloatingMenuView { show() { this.element.style.visibility = 'visible' this.element.style.opacity = '1' - // attach from body - document.body.appendChild(this.element) + // attach to editor's parent element + this.view.dom.parentElement?.appendChild(this.element) } hide() { this.element.style.visibility = 'hidden' this.element.style.opacity = '0' - // remove from body + // remove from the parent element this.element.remove() } diff --git a/packages/react/src/BubbleMenu.tsx b/packages/react/src/BubbleMenu.tsx index 5c70ceecfe6..24ff74b9102 100644 --- a/packages/react/src/BubbleMenu.tsx +++ b/packages/react/src/BubbleMenu.tsx @@ -6,69 +6,79 @@ import { useCurrentEditor } from './Context.js' type Optional = Pick, K> & Omit; -export type BubbleMenuProps = Omit, 'element' | 'editor'> & { +export type BubbleMenuProps = Omit< + Optional, + 'element' | 'editor' +> & { editor: BubbleMenuPluginProps['editor'] | null; className?: string; children: React.ReactNode; updateDelay?: number; resizeDelay?: number; options?: BubbleMenuPluginProps['options']; -}; +} & Partial>; + +export const BubbleMenu = React.forwardRef(({ + 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 + } -export const BubbleMenu = (props: BubbleMenuProps) => { - const menuEl = useRef(document.createElement('div')) const { editor: currentEditor } = useCurrentEditor() useEffect(() => { - menuEl.current.style.visibility = 'hidden' - menuEl.current.style.position = 'absolute' + const bubbleMenuElement = menuEl.current + + bubbleMenuElement.style.visibility = 'hidden' + bubbleMenuElement.style.position = 'absolute' - if (props.editor?.isDestroyed || currentEditor?.isDestroyed) { + if (editor?.isDestroyed || currentEditor?.isDestroyed) { return } - const { - pluginKey = 'bubbleMenu', editor, updateDelay, resizeDelay, shouldShow = null, - } = props + const attachToEditor = editor || currentEditor - const menuEditor = editor || currentEditor - - if (!menuEditor) { - console.warn('BubbleMenu component is not rendered inside of an editor component or does not have editor prop.') + 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: menuEditor, - element: menuEl.current, + editor: attachToEditor, + element: bubbleMenuElement, pluginKey, shouldShow, - options: props.options, + options, }) - menuEditor.registerPlugin(plugin) + attachToEditor.registerPlugin(plugin) return () => { - menuEditor.unregisterPlugin(pluginKey) + attachToEditor.unregisterPlugin(pluginKey) window.requestAnimationFrame(() => { - if (menuEl.current.parentNode) { - menuEl.current.parentNode.removeChild(menuEl.current) + if (bubbleMenuElement.parentNode) { + bubbleMenuElement.parentNode.removeChild(bubbleMenuElement) } }) } - }, [props.editor, currentEditor]) - - const portal = createPortal( - ( -
- {props.children} -
- ), menuEl.current, - ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, currentEditor]) - return ( - <>{portal} - ) -} + return createPortal(<>{children}, menuEl.current) +}) diff --git a/packages/react/src/FloatingMenu.tsx b/packages/react/src/FloatingMenu.tsx index 3c1d2a4fea2..f28c0d1682d 100644 --- a/packages/react/src/FloatingMenu.tsx +++ b/packages/react/src/FloatingMenu.tsx @@ -1,77 +1,78 @@ import { FloatingMenuPlugin, FloatingMenuPluginProps } from '@tiptap/extension-floating-menu' -import React, { - useEffect, useRef, -} from 'react' +import React, { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { useCurrentEditor } from './Context.js' -type Optional = Pick, K> & Omit +type Optional = Pick, K> & Omit; -export type FloatingMenuProps = Omit, 'element' | 'editor'> & { +export type FloatingMenuProps = Omit< + Optional, + 'element' | 'editor' +> & { editor: FloatingMenuPluginProps['editor'] | null; - className?: string, - children: React.ReactNode - options?: FloatingMenuPluginProps['options'] -} + className?: string; + children: React.ReactNode; + options?: FloatingMenuPluginProps['options']; +} & Partial>; + +export const FloatingMenu = React.forwardRef(({ + pluginKey = 'floatingMenu', + 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 + } -export const FloatingMenu = (props: FloatingMenuProps) => { - const menuEl = useRef(document.createElement('div')) const { editor: currentEditor } = useCurrentEditor() useEffect(() => { - menuEl.current.style.visibility = 'hidden' - menuEl.current.style.position = 'absolute' + const floatingMenuElement = menuEl.current + + floatingMenuElement.style.visibility = 'hidden' + floatingMenuElement.style.position = 'absolute' - if (props.editor?.isDestroyed || currentEditor?.isDestroyed) { + if (editor?.isDestroyed || currentEditor?.isDestroyed) { return } - const { - pluginKey = 'floatingMenu', - editor, - options, - shouldShow = null, - } = props - - const menuEditor = editor || currentEditor + const attachToEditor = editor || currentEditor - if (!menuEditor) { - console.warn('FloatingMenu component is not rendered inside of an editor component or does not have editor prop.') + if (!attachToEditor) { + console.warn( + 'FloatingMenu component is not rendered inside of an editor component or does not have editor prop.', + ) return } const plugin = FloatingMenuPlugin({ + editor: attachToEditor, + element: floatingMenuElement, pluginKey, - editor: menuEditor, - element: menuEl.current, - options, shouldShow, + options, }) - menuEditor.registerPlugin(plugin) + attachToEditor.registerPlugin(plugin) + return () => { - menuEditor.unregisterPlugin(pluginKey) + attachToEditor.unregisterPlugin(pluginKey) window.requestAnimationFrame(() => { - if (menuEl.current.parentNode) { - menuEl.current.parentNode.removeChild(menuEl.current) + if (floatingMenuElement.parentNode) { + floatingMenuElement.parentNode.removeChild(floatingMenuElement) } }) } - }, [ - props.editor, - currentEditor, - ]) - - const portal = createPortal( - ( -
- {props.children} -
- ), menuEl.current, - ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, currentEditor]) - return ( - <>{portal} - ) -} + return createPortal(<>{children}, menuEl.current) +}) From bd6c257bf62cba5baa2f70c040d42101e207a556 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 7 Nov 2024 14:52:42 +0100 Subject: [PATCH 02/11] fix(react): change types to reflect the new default value --- packages/react/src/useEditor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/useEditor.ts b/packages/react/src/useEditor.ts index 5cc56362847..d41fdba834b 100644 --- a/packages/react/src/useEditor.ts +++ b/packages/react/src/useEditor.ts @@ -288,9 +288,9 @@ class EditorInstanceManager { * @example const editor = useEditor({ extensions: [...] }) */ export function useEditor( - options: UseEditorOptions & { immediatelyRender: true }, + options: UseEditorOptions & { immediatelyRender: false }, deps?: DependencyList -): Editor; +): Editor | null; /** * This hook allows you to create an editor instance. @@ -299,7 +299,7 @@ export function useEditor( * @returns The editor instance * @example const editor = useEditor({ extensions: [...] }) */ -export function useEditor(options?: UseEditorOptions, deps?: DependencyList): Editor | null; +export function useEditor(options: UseEditorOptions, deps?: DependencyList): Editor; export function useEditor( options: UseEditorOptions = {}, From 01d6dc89a8f18278b05119b5e3b2b0192d54671b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 7 Nov 2024 14:53:39 +0100 Subject: [PATCH 03/11] feat: make the example much better --- .../Accessibility/React/InsertMenu.tsx | 71 +++++++--- .../Examples/Accessibility/React/MenuBar.tsx | 47 +++---- .../Accessibility/React/NodeTypeDropdown.tsx | 4 +- .../Examples/Accessibility/React/TextMenu.tsx | 14 +- .../Examples/Accessibility/React/index.tsx | 52 ++++---- .../Examples/Accessibility/React/styles.scss | 59 +++++++++ .../Accessibility/React/useFocusMenubar.ts | 97 +++++++++----- .../src/floating-menu-plugin.ts | 3 - packages/react/src/BubbleMenu.tsx | 122 ++++++++++-------- packages/react/src/FloatingMenu.tsx | 15 ++- 10 files changed, 306 insertions(+), 178 deletions(-) diff --git a/demos/src/Examples/Accessibility/React/InsertMenu.tsx b/demos/src/Examples/Accessibility/React/InsertMenu.tsx index 9842e8ebb10..f5735e761d6 100644 --- a/demos/src/Examples/Accessibility/React/InsertMenu.tsx +++ b/demos/src/Examples/Accessibility/React/InsertMenu.tsx @@ -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(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 ( { - // 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 + + + + ) } diff --git a/demos/src/Examples/Accessibility/React/MenuBar.tsx b/demos/src/Examples/Accessibility/React/MenuBar.tsx index cae4b26eaeb..ccfafb42d2f 100644 --- a/demos/src/Examples/Accessibility/React/MenuBar.tsx +++ b/demos/src/Examples/Accessibility/React/MenuBar.tsx @@ -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(null); + const containerRef = useRef(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 (
- -
- ); -}; + ) +} diff --git a/demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx b/demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx index c3e6eeb4873..df69fc55713 100644 --- a/demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx +++ b/demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx @@ -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)} {isOpen && (
{ @@ -46,14 +46,18 @@ export function TextMenu({ editor }: { editor: Editor }) { { - // 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} > diff --git a/demos/src/Examples/Accessibility/React/index.tsx b/demos/src/Examples/Accessibility/React/index.tsx index c7ba878799d..c5e896f7527 100644 --- a/demos/src/Examples/Accessibility/React/index.tsx +++ b/demos/src/Examples/Accessibility/React/index.tsx @@ -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: ` +

Accessibility Demo

+

Tab into the demo & navigate around with only your keyboard

+

Use Alt + F10 to focus the menu bar

+ `, editorProps: { - attributes: (state): Record => { - 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 ( -
+
- ); -}; + ) +} diff --git a/demos/src/Examples/Accessibility/React/styles.scss b/demos/src/Examples/Accessibility/React/styles.scss index 468746e302a..2c3fea393c4 100644 --- a/demos/src/Examples/Accessibility/React/styles.scss +++ b/demos/src/Examples/Accessibility/React/styles.scss @@ -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, diff --git a/demos/src/Examples/Accessibility/React/useFocusMenubar.ts b/demos/src/Examples/Accessibility/React/useFocusMenubar.ts index e905a08bb24..f5a32c5a62e 100644 --- a/demos/src/Examples/Accessibility/React/useFocusMenubar.ts +++ b/demos/src/Examples/Accessibility/React/useFocusMenubar.ts @@ -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 } } diff --git a/packages/extension-floating-menu/src/floating-menu-plugin.ts b/packages/extension-floating-menu/src/floating-menu-plugin.ts index 8dc0f709ed1..f01317348b0 100644 --- a/packages/extension-floating-menu/src/floating-menu-plugin.ts +++ b/packages/extension-floating-menu/src/floating-menu-plugin.ts @@ -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 } diff --git a/packages/react/src/BubbleMenu.tsx b/packages/react/src/BubbleMenu.tsx index 24ff74b9102..4a10068928c 100644 --- a/packages/react/src/BubbleMenu.tsx +++ b/packages/react/src/BubbleMenu.tsx @@ -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>; +} & React.HTMLAttributes; -export const BubbleMenu = React.forwardRef(({ - pluginKey = 'bubbleMenu', - editor, - updateDelay, - resizeDelay, - shouldShow = null, - options, - children, - ...restProps -}, ref) => { - const menuEl = useRef(Object.assign(document.createElement('div'), restProps)) +export const BubbleMenu = React.forwardRef( + ( + { + pluginKey = 'bubbleMenu', + editor, + updateDelay, + resizeDelay, + shouldShow = null, + options, + children, + ...restProps + }, + ref, + ) => { + const menuEl = useRef(document.createElement('div')) - if (typeof ref === 'function') { - ref(menuEl.current) - } else if (ref) { - ref.current = menuEl.current - } + if (typeof ref === 'function') { + ref(menuEl.current) + } else if (ref) { + ref.current = menuEl.current + } - const { editor: currentEditor } = useCurrentEditor() + const { editor: currentEditor } = useCurrentEditor() - useEffect(() => { - const bubbleMenuElement = menuEl.current + useEffect(() => { + const bubbleMenuElement = menuEl.current - bubbleMenuElement.style.visibility = 'hidden' - bubbleMenuElement.style.position = 'absolute' + bubbleMenuElement.style.visibility = 'hidden' + bubbleMenuElement.style.position = 'absolute' - if (editor?.isDestroyed || currentEditor?.isDestroyed) { - return - } + if (editor?.isDestroyed || currentEditor?.isDestroyed) { + return + } - const attachToEditor = editor || currentEditor + 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 - } + 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, - }) + const plugin = BubbleMenuPlugin({ + updateDelay, + resizeDelay, + editor: attachToEditor, + element: bubbleMenuElement, + pluginKey, + shouldShow, + options, + }) - attachToEditor.registerPlugin(plugin) + 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 () => { + attachToEditor.unregisterPlugin(pluginKey) + window.requestAnimationFrame(() => { + if (bubbleMenuElement.parentNode) { + bubbleMenuElement.parentNode.removeChild(bubbleMenuElement) + } + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, currentEditor]) - return createPortal(<>{children}, menuEl.current) -}) + return createPortal( +
+ {children} +
, + menuEl.current, + ) + }, +) diff --git a/packages/react/src/FloatingMenu.tsx b/packages/react/src/FloatingMenu.tsx index f28c0d1682d..bdf73809e2d 100644 --- a/packages/react/src/FloatingMenu.tsx +++ b/packages/react/src/FloatingMenu.tsx @@ -11,10 +11,8 @@ export type FloatingMenuProps = Omit< 'element' | 'editor' > & { editor: FloatingMenuPluginProps['editor'] | null; - className?: string; - children: React.ReactNode; options?: FloatingMenuPluginProps['options']; -} & Partial>; +} & React.HTMLAttributes; export const FloatingMenu = React.forwardRef(({ pluginKey = 'floatingMenu', @@ -24,7 +22,7 @@ export const FloatingMenu = React.forwardRef( 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( // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor, currentEditor]) - return createPortal(<>{children}, menuEl.current) + return createPortal( +
+ {children} +
, + menuEl.current, + ) }) From c33e1991fbead9b8d1fb9073ac69fc3ea826c481 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 Nov 2024 11:38:49 +0100 Subject: [PATCH 04/11] refactor: clean up the demo a bit --- .../Accessibility/React/InsertMenu.tsx | 3 + .../Examples/Accessibility/React/MenuBar.tsx | 248 +++++++++++++++++- .../Accessibility/React/NodeTypeDropdown.tsx | 165 ------------ .../Examples/Accessibility/React/TextMenu.tsx | 3 + .../Examples/Accessibility/React/index.tsx | 4 + 5 files changed, 250 insertions(+), 173 deletions(-) delete mode 100644 demos/src/Examples/Accessibility/React/NodeTypeDropdown.tsx diff --git a/demos/src/Examples/Accessibility/React/InsertMenu.tsx b/demos/src/Examples/Accessibility/React/InsertMenu.tsx index f5735e761d6..690bc0556cd 100644 --- a/demos/src/Examples/Accessibility/React/InsertMenu.tsx +++ b/demos/src/Examples/Accessibility/React/InsertMenu.tsx @@ -3,6 +3,9 @@ import React, { useRef } from 'react' import { useFocusMenubar } from './useFocusMenubar.js' +/** + * A floating menu for inserting new elements like lists, horizontal rules, etc. + */ export function InsertMenu({ editor }: { editor: Editor }) { const containerRef = useRef(null) diff --git a/demos/src/Examples/Accessibility/React/MenuBar.tsx b/demos/src/Examples/Accessibility/React/MenuBar.tsx index ccfafb42d2f..a1414a6e334 100644 --- a/demos/src/Examples/Accessibility/React/MenuBar.tsx +++ b/demos/src/Examples/Accessibility/React/MenuBar.tsx @@ -1,15 +1,249 @@ import { Editor } from '@tiptap/core' -import React, { useRef } from 'react' +import { useEditorState } from '@tiptap/react' +import React, { useEffect, useRef, useState } from 'react' -import { NodeTypeDropdown } from './NodeTypeDropdown.js' import { useFocusMenubar } from './useFocusMenubar.js' /** - * An accessible menu bar for the editor + * Handles the heading dropdown */ -export const MenuBar = ({ editor }: { editor: Editor }) => { +function NodeTypeDropdown({ editor }: { editor: Editor }) { + const dropdownRef = useRef(null) + const menuRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + + const editorState = useEditorState({ + editor, + selector: ctx => { + const activeNode = ctx.editor.state.selection.$from.node(1) + + return { + activeNodeType: activeNode?.type.name ?? 'paragraph', + isParagraph: ctx.editor.isActive('paragraph'), + isHeading1: ctx.editor.isActive('heading', { level: 1 }), + isHeading2: ctx.editor.isActive('heading', { level: 2 }), + isHeading3: ctx.editor.isActive('heading', { level: 3 }), + isHeading4: ctx.editor.isActive('heading', { level: 4 }), + isHeading5: ctx.editor.isActive('heading', { level: 5 }), + isHeading6: ctx.editor.isActive('heading', { level: 6 }), + isBulletList: ctx.editor.isActive('bulletList'), + isOrderedList: ctx.editor.isActive('orderedList'), + isCodeBlock: ctx.editor.isActive('codeBlock'), + isBlockquote: ctx.editor.isActive('blockquote'), + } + }, + }) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + document.addEventListener('click', handleClickOutside) + return () => { + document.removeEventListener('click', handleClickOutside) + } + }, []) + + return ( +
+ + {isOpen && ( + + )} +
+ ) +} + +/** + * An accessible static top menu bar for the editor + */ +export function MenuBar({ editor }: { editor: Editor }) { const containerRef = useRef(null) + const editorState = useEditorState({ + editor, + selector: ctx => { + return { + canUndo: ctx.editor.can().chain().focus().undo() + .run(), + canRedo: ctx.editor.can().chain().focus().redo() + .run(), + } + }, + }) + useFocusMenubar({ ref: containerRef, editor, @@ -32,8 +266,7 @@ export const MenuBar = ({ editor }: { editor: Editor }) => { - {isOpen && ( - - )} -
- ) -} diff --git a/demos/src/Examples/Accessibility/React/TextMenu.tsx b/demos/src/Examples/Accessibility/React/TextMenu.tsx index 09cef55ca07..4090b824933 100644 --- a/demos/src/Examples/Accessibility/React/TextMenu.tsx +++ b/demos/src/Examples/Accessibility/React/TextMenu.tsx @@ -4,6 +4,9 @@ import React, { useRef } from 'react' import { useFocusMenubar } from './useFocusMenubar.js' +/** + * Handles formatting text with marks like bold, italic, etc. + */ export function TextMenu({ editor }: { editor: Editor }) { const containerRef = useRef(null) diff --git a/demos/src/Examples/Accessibility/React/index.tsx b/demos/src/Examples/Accessibility/React/index.tsx index c5e896f7527..efd38b16792 100644 --- a/demos/src/Examples/Accessibility/React/index.tsx +++ b/demos/src/Examples/Accessibility/React/index.tsx @@ -32,9 +32,13 @@ export default () => { return (
+ {/* The static menu bar */} + {/* The editor content */} + {/* Handles marks: bold, italic, etc. using a bubble menu */} + {/* Handles nodes: headings, lists, etc. using a floating menu */}
) From d4016aaa6d0fe821361ac7f81bda134499bcc812 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 Nov 2024 11:41:07 +0100 Subject: [PATCH 05/11] style: add styling to dropdown --- .../Examples/Accessibility/React/styles.scss | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/demos/src/Examples/Accessibility/React/styles.scss b/demos/src/Examples/Accessibility/React/styles.scss index 2c3fea393c4..7e4143be7ca 100644 --- a/demos/src/Examples/Accessibility/React/styles.scss +++ b/demos/src/Examples/Accessibility/React/styles.scss @@ -10,8 +10,24 @@ right: 0; gap: 0.25rem; padding: 0.25rem 0; - background-color: var(--gray-1); + background-color: var(--white); z-index: 1000; + + button { + background-color: unset; + + &:hover { + background-color: var(--gray-3); + } + + &.is-active { + background-color: var(--purple); + + &:hover { + background-color: var(--purple-contrast); + } + } + } } From 85a5cef1e6f51d55040f5c70c04e66925d3bf756 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 Nov 2024 13:35:59 +0100 Subject: [PATCH 06/11] fix: allow arrow down & arrow up to move nav --- demos/src/Examples/Accessibility/React/useFocusMenubar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/src/Examples/Accessibility/React/useFocusMenubar.ts b/demos/src/Examples/Accessibility/React/useFocusMenubar.ts index f5a32c5a62e..f340bfc8461 100644 --- a/demos/src/Examples/Accessibility/React/useFocusMenubar.ts +++ b/demos/src/Examples/Accessibility/React/useFocusMenubar.ts @@ -124,14 +124,14 @@ export function useFocusMenubar({ return true } // Handle arrow navigation within the menu bar - if (event.key === 'ArrowRight') { + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { if (focusNextButton(event.target as HTMLButtonElement)) { event.preventDefault() return true } } - if (event.key === 'ArrowLeft') { + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { if (focusPreviousButton(event.target as HTMLButtonElement)) { event.preventDefault() return true From 2e0cf0b832204e1e2e32cf00ae10de89aa38ab8b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 8 Nov 2024 14:00:17 +0100 Subject: [PATCH 07/11] fix: add selection states --- .../Accessibility/React/InsertMenu.tsx | 2 ++ .../Examples/Accessibility/React/MenuBar.tsx | 23 +++++++++++++++++-- .../Examples/Accessibility/React/TextMenu.tsx | 5 +++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/demos/src/Examples/Accessibility/React/InsertMenu.tsx b/demos/src/Examples/Accessibility/React/InsertMenu.tsx index 690bc0556cd..04800e2bf7f 100644 --- a/demos/src/Examples/Accessibility/React/InsertMenu.tsx +++ b/demos/src/Examples/Accessibility/React/InsertMenu.tsx @@ -52,6 +52,7 @@ export function InsertMenu({ editor }: { editor: Editor }) { {isOpen && (
{ // Is it not within the dropdown? @@ -100,6 +103,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isHeading1 ? 'is-active' : ''} + aria-pressed={editorState.isHeading1} tabIndex={-1} role="menuitem" aria-label="H1" @@ -112,6 +116,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isHeading2 ? 'is-active' : ''} + aria-pressed={editorState.isHeading2} tabIndex={-1} role="menuitem" aria-label="H2" @@ -124,6 +129,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isHeading3 ? 'is-active' : ''} + aria-pressed={editorState.isHeading3} tabIndex={-1} role="menuitem" aria-label="H3" @@ -136,6 +142,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isHeading4 ? 'is-active' : ''} + aria-pressed={editorState.isHeading4} tabIndex={-1} role="menuitem" aria-label="H4" @@ -148,6 +155,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isHeading5 ? 'is-active' : ''} + aria-pressed={editorState.isHeading5} tabIndex={-1} role="menuitem" aria-label="H5" @@ -160,6 +168,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isHeading6 ? 'is-active' : ''} + aria-pressed={editorState.isHeading6} tabIndex={-1} role="menuitem" aria-label="H6" @@ -172,6 +181,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isBulletList ? 'is-active' : ''} + aria-pressed={editorState.isBulletList} tabIndex={-1} role="menuitem" aria-label="Bullet list" @@ -184,6 +194,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isOrderedList ? 'is-active' : ''} + aria-pressed={editorState.isOrderedList} tabIndex={-1} role="menuitem" aria-label="Ordered List" @@ -196,6 +207,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isCodeBlock ? 'is-active' : ''} + aria-pressed={editorState.isCodeBlock} tabIndex={-1} role="menuitem" aria-label="Code block" @@ -208,6 +220,7 @@ function NodeTypeDropdown({ editor }: { editor: Editor }) { setIsOpen(false) }} className={editorState.isBlockquote ? 'is-active' : ''} + aria-pressed={editorState.isBlockquote} tabIndex={-1} role="menuitem" aria-label="Blockquote" @@ -261,7 +274,13 @@ export function MenuBar({ editor }: { editor: Editor }) { } return ( -
+