Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 6 additions & 25 deletions packages/ariakit/src/comments/Comment.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { Group as AriakitGroup } from "@ariakit/react";

import { assertEmpty } from "@blocknote/core";
import {
ComponentProps,
useDictionary,
useFocusWithin,
} from "@blocknote/react";
import { forwardRef, useState } from "react";
import { ComponentProps, useDictionary } from "@blocknote/react";
import { forwardRef } from "react";

const AuthorInfo = forwardRef<
HTMLDivElement,
Expand Down Expand Up @@ -64,27 +60,12 @@ export const Comment = forwardRef<

assertEmpty(rest, false);

const [hovered, setHovered] = useState(false);
const { focused, ref: focusRef } = useFocusWithin();

const doShowActions =
actions &&
(showActions === true ||
showActions === undefined ||
(showActions === "hover" && hovered) ||
focused ||
emojiPickerOpen);

return (
<AriakitGroup
ref={ref}
className={className}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{doShowActions ? (
<AriakitGroup ref={ref} className={className}>
{actions && (showActions !== false || emojiPickerOpen) ? (
<AriakitGroup
ref={focusRef}
className={"bn-comment-toolbar"}
data-show={emojiPickerOpen ? true : (showActions ?? true)}
style={{
position: "absolute",
right: 0,
Expand Down
18 changes: 5 additions & 13 deletions packages/mantine/src/comments/Comment.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { assertEmpty } from "@blocknote/core";
import { ComponentProps, mergeRefs, useDictionary } from "@blocknote/react";
import { ComponentProps, useDictionary } from "@blocknote/react";
import { Avatar, Group, Skeleton, Text } from "@mantine/core";
import { useHover } from "@mantine/hooks";
import { forwardRef } from "react";

const AuthorInfo = forwardRef<
Expand Down Expand Up @@ -64,21 +63,14 @@ export const Comment = forwardRef<
...rest
} = props;

const { hovered, ref: hoverRef } = useHover();
const mergedRef = mergeRefs([ref, hoverRef]);
assertEmpty(rest, false);

const doShowActions =
actions &&
(showActions === true ||
showActions === undefined ||
(showActions === "hover" && hovered) ||
emojiPickerOpen);

return (
<Group pos="relative" ref={mergedRef} className={className}>
{doShowActions ? (
<Group pos="relative" ref={ref} className={className}>
{actions && (showActions !== false || emojiPickerOpen) ? (
<Group
className={"bn-comment-toolbar"}
data-show={emojiPickerOpen ? true : (showActions ?? true)}
style={{
position: "absolute",
right: 0,
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/components/Comments/Comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export const Comment = ({
>
<Components.Generic.Toolbar.Button
key={"add-reaction"}
label={dict.comments.actions.add_reaction}
mainTooltip={dict.comments.actions.add_reaction}
variant="compact"
>
Expand All @@ -281,6 +282,7 @@ export const Comment = ({
(thread.resolved ? (
<Components.Generic.Toolbar.Button
key={"reopen"}
label={dict.comments.actions.reopen}
mainTooltip={dict.comments.actions.reopen}
variant="compact"
onClick={onReopen}
Expand All @@ -290,6 +292,7 @@ export const Comment = ({
) : (
<Components.Generic.Toolbar.Button
key={"resolve"}
label={dict.comments.actions.resolve}
mainTooltip={dict.comments.actions.resolve}
variant="compact"
onClick={onResolve}
Expand All @@ -306,6 +309,7 @@ export const Comment = ({
<Components.Generic.Menu.Trigger>
<Components.Generic.Toolbar.Button
key={"more-actions"}
label={dict.comments.actions.more_actions}
mainTooltip={dict.comments.actions.more_actions}
variant="compact"
>
Expand Down
5 changes: 5 additions & 0 deletions packages/react/src/components/Comments/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ReactNode, useState } from "react";
import { useBlockNoteContext } from "../../editor/BlockNoteContext.js";
import { useComponentsContext } from "../../editor/ComponentsContext.js";
import { usePortalElement } from "../../editor/PortalElementOverride.js";
import { useDictionary } from "../../i18n/dictionary.js";
import Picker from "./EmojiMartPicker.js";

export const EmojiPicker = (props: {
Expand All @@ -11,6 +12,7 @@ export const EmojiPicker = (props: {
children: ReactNode;
}) => {
const [open, setOpen] = useState(false);
const dict = useDictionary();

const Components = useComponentsContext()!;
const blockNoteContext = useBlockNoteContext()!;
Expand All @@ -20,6 +22,9 @@ export const EmojiPicker = (props: {
<Components.Generic.Popover.Root open={open} portalElement={portalElement}>
<Components.Generic.Popover.Trigger>
<div
aria-label={dict.comments.actions.add_reaction}
// The child control handles keyboard activation.
tabIndex={-1}
onClick={(event) => {
// Needed as the Picker component's onClickOutside handler
// fires immediately after otherwise, preventing the popover
Expand Down
34 changes: 34 additions & 0 deletions packages/react/src/components/Comments/a11y/Comment.a11y.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { focusCommentAction, openComments } from "./helpers.js";
import { expect, test } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
import { pressKey } from "@root/tests/a11y/helpers.js";

test("comment actions toolbar: Add reaction", async () => {
await openComments();
await focusCommentAction("Add reaction");
await pressKey("Enter");
await expect
.element(page.getByRole("dialog", { name: "Add reaction" }))
.toBeVisible();
});

test("comment actions toolbar: Resolve", async () => {
const { threadStore, threadId } = await openComments();
await focusCommentAction("Resolve");
await pressKey("Enter");
await expect.poll(() => threadStore.getThread(threadId).resolved).toBe(true);
});

test("comment actions toolbar: Re-open", async () => {
const { threadStore, threadId } = await openComments({ resolved: true });
await focusCommentAction("Re-open");
await pressKey("Enter");
await expect.poll(() => threadStore.getThread(threadId).resolved).toBe(false);
});

test("comment actions toolbar: More actions", async () => {
await openComments();
await focusCommentAction("More actions");
await pressKey("Enter");
await expect.element(page.getByRole("menu")).toBeVisible();
});
65 changes: 65 additions & 0 deletions packages/react/src/components/Comments/a11y/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createUserStore } from "@blocknote/core";
import {
CommentsExtension,
DefaultThreadStoreAuth,
} from "@blocknote/core/comments";
import { YjsThreadStore } from "@blocknote/core/y";
import { ThreadsSidebar } from "@blocknote/react";
import { createElement } from "react";
import * as Y from "@y/y";

Check failure on line 9 in packages/react/src/components/Comments/a11y/helpers.ts

View workflow job for this annotation

GitHub Actions / Build

typescript(TS2307)

packages/react/src/components/Comments/a11y/helpers.ts:9:20: Cannot find module '@y/y' or its corresponding type declarations.

import { expect } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
import { openEditor, pressKey } from "@root/tests/a11y/helpers.js";

export async function openComments({ resolved = false } = {}) {
const doc = new Y.Doc();
const threadStore = new YjsThreadStore(
"user",
doc.get("threads"),
new DefaultThreadStoreAuth("user", "editor"),
);
const thread = await threadStore.createThread({
initialComment: { body: [{ type: "paragraph", content: "A comment" }] },
});
if (resolved) {
await threadStore.resolveThread({ threadId: thread.id });
}
const users = createUserStore(async () => [
{
id: "user",
username: "Test user",
avatarUrl: "/image.svg",
},
]);
await users.loadUsers(["user"]);
await openEditor([{ type: "paragraph", content: "Commented document" }], {
extensions: [CommentsExtension({ threadStore, resolveUsers: users })],
children: createElement(ThreadsSidebar, { filter: "all" }),
});
return { threadStore, threadId: thread.id };
}

export async function focusCommentAction(control: string) {
const toolbar = page.getByRole("toolbar");
const button = toolbar.getByRole("button", { name: control, exact: true });
await expect.element(button).toBeEnabled();
// Start at "Before editor" and reach the action without hovering the comment.
for (let step = 0; step < 10; step++) {
if (button.element() === document.activeElement) {
break;
}
await pressKey("Tab");
}
await expect
.element(button, { message: `${control} is reachable with Tab` })
.toHaveFocus();
await expect
.poll(
() =>
getComputedStyle(button.element().closest(".bn-comment-toolbar")!)
.opacity,
)
.toBe("1");
return { toolbar, button };
}
12 changes: 12 additions & 0 deletions packages/react/src/editor/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,18 @@ SideMenuController offsets its position to keep it centered on the line. */
flex-grow: 1;
}

/* Keep hover actions in the tab order and reveal them when focus enters. */
.bn-comment-toolbar[data-show="hover"] {
opacity: 0;
pointer-events: none;
}

*:hover > .bn-comment-toolbar[data-show="hover"],
*:focus-within > .bn-comment-toolbar[data-show="hover"] {
opacity: 1;
pointer-events: auto;
}

/* Thread sidebar styling */
.bn-threads-sidebar {
border-radius: var(--bn-border-radius-medium);
Expand Down
33 changes: 8 additions & 25 deletions packages/shadcn/src/comments/Comment.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { assertEmpty } from "@blocknote/core";
import {
ComponentProps,
useDictionary,
useFocusWithin,
} from "@blocknote/react";
import { forwardRef, useState } from "react";
import { ComponentProps, useDictionary } from "@blocknote/react";
import { forwardRef } from "react";

import { cn } from "../lib/utils.js";
import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js";
Expand Down Expand Up @@ -77,26 +73,13 @@ export const Comment = forwardRef<

assertEmpty(rest);

const [hovered, setHovered] = useState(false);
const { focused, ref: focusRef } = useFocusWithin();

const doShowActions =
actions &&
(showActions === true ||
showActions === undefined ||
(showActions === "hover" && hovered) ||
focused ||
emojiPickerOpen);

return (
<div
ref={ref}
className={cn(className, "relative flex flex-col gap-2")}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{doShowActions ? (
<div className={"absolute top-0 right-0 z-10"} ref={focusRef}>
<div ref={ref} className={cn(className, "relative flex flex-col gap-2")}>
{actions && (showActions !== false || emojiPickerOpen) ? (
<div
className={"bn-comment-toolbar absolute top-0 right-0 z-10"}
data-show={emojiPickerOpen ? true : (showActions ?? true)}
>
{actions}
</div>
) : null}
Expand Down
31 changes: 24 additions & 7 deletions tests/a11y/helpers.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { PartialBlock } from "@blocknote/core";
import type { ExtensionFactoryInstance, PartialBlock } from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import type { ReactNode } from "react";
import { render } from "vitest-browser-react";
import axe from "axe-core";
import { expect, type TestContext } from "vite-plus/test";
Expand All @@ -27,17 +28,23 @@ function EditorFixture({
initialContent,
editable,
theme,
extensions,
children,
}: {
initialContent: PartialBlock[];
editable: boolean;
theme: "light" | "dark";
extensions?: ExtensionFactoryInstance[];
children?: ReactNode;
}) {
const editor = useCreateBlockNote({ initialContent });
const editor = useCreateBlockNote({ initialContent, extensions });
return (
<main style={{ maxWidth: 900, margin: "80px auto", padding: 24 }}>
<h1>BlockNote accessibility fixture</h1>
<button type="button">Before editor</button>
<BlockNoteView editor={editor} theme={theme} editable={editable} />
<BlockNoteView editor={editor} theme={theme} editable={editable}>
{children}
</BlockNoteView>
<button type="button">After editor</button>
</main>
);
Expand All @@ -48,19 +55,29 @@ export async function openEditor(
{
editable = true,
theme = "light",
}: { editable?: boolean; theme?: "light" | "dark" } = {},
extensions,
children,
}: {
editable?: boolean;
theme?: "light" | "dark";
extensions?: ExtensionFactoryInstance[];
children?: ReactNode;
} = {},
) {
await page.viewport(1280, 900);
await render(
<EditorFixture
initialContent={initialContent}
editable={editable}
theme={theme}
/>,
extensions={extensions}
>
{children}
</EditorFixture>,
);
await expect.element(page.getByRole("textbox")).toBeVisible();
await expect.element(page.getByRole("textbox").first()).toBeVisible();
await expect
.element(page.getByRole("textbox"))
.element(page.getByRole("textbox").first())
.toHaveAttribute("contenteditable", String(editable));
await document.fonts.ready;
if (screenReader) {
Expand Down
Loading