@gcforms/editor 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ "use client";
2
+ import React, { useState, useCallback, useEffect, useRef, KeyboardEvent } from "react";
3
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
4
+ import { $isHeadingNode, $createHeadingNode } from "@lexical/rich-text";
5
+ import { mergeRegister, $getNearestNodeOfType } from "@lexical/utils";
6
+
7
+ import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
8
+
9
+ // @TODO: pass in this dependency or otherwise incorporate into package
10
+ import { useTranslation } from "@i18n/client";
11
+
12
+ import {
13
+ $isListNode,
14
+ INSERT_ORDERED_LIST_COMMAND,
15
+ INSERT_UNORDERED_LIST_COMMAND,
16
+ REMOVE_LIST_COMMAND,
17
+ ListNode,
18
+ } from "@lexical/list";
19
+
20
+ import {
21
+ FORMAT_TEXT_COMMAND,
22
+ $getSelection,
23
+ $isRangeSelection,
24
+ SELECTION_CHANGE_COMMAND,
25
+ $createParagraphNode,
26
+ } from "lexical";
27
+
28
+ import { $wrapNodes } from "@lexical/selection";
29
+ import { sanitizeUrl } from "../../utils/url";
30
+ import { useEditorFocus } from "../../hooks/useEditorFocus";
31
+ import { getSelectedNode } from "../../utils/getSelectedNode";
32
+ import { ToolTip } from "../../ToolTip";
33
+ import { H2Icon } from "../../icons/H2Icon";
34
+ import { H3Icon } from "../../icons/H3Icon";
35
+ import { BoldIcon } from "../../icons/BoldIcon";
36
+ import { ItalicIcon } from "../../icons/ItalicIcon";
37
+ import { BulletListIcon } from "../../icons/BulletListIcon";
38
+ import { NumberedListIcon } from "../../icons/NumberedListIcon";
39
+ import { LinkIcon } from "../../icons/LinkIcon";
40
+ import "./styles.css";
41
+
42
+ const blockTypeToBlockName = {
43
+ bullet: "Bulleted List",
44
+ check: "Check List",
45
+ code: "Code Block",
46
+ h1: "Heading 1",
47
+ h2: "Heading 2",
48
+ h3: "Heading 3",
49
+ h4: "Heading 4",
50
+ h5: "Heading 5",
51
+ h6: "Heading 6",
52
+ number: "Numbered List",
53
+ paragraph: "Normal",
54
+ quote: "Quote",
55
+ };
56
+
57
+ const LowPriority = 1;
58
+ type HeadingTagType = "h2" | "h3" | "h4" | "h5";
59
+
60
+ export default function ToolbarPlugin({ editorId }: { editorId: string }) {
61
+ const [editor] = useLexicalComposerContext();
62
+ const [isBold, setIsBold] = useState(false);
63
+ const [isItalic, setIsItalic] = useState(false);
64
+ const [isLink, setIsLink] = useState(false);
65
+ const [, setSelectedElementKey] = useState("");
66
+ const [blockType, setBlockType] = useState("paragraph");
67
+
68
+ const [isEditable] = useState(() => editor.isEditable());
69
+
70
+ const insertLink = useCallback(() => {
71
+ if (!isLink) {
72
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl("https://"));
73
+ } else {
74
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
75
+ }
76
+ }, [editor, isLink]);
77
+
78
+ const [items] = useState([
79
+ { id: 1, txt: "heading2" },
80
+ { id: 2, txt: "heading3" },
81
+ { id: 3, txt: "bold" },
82
+ { id: 4, txt: "italic" },
83
+ { id: 5, txt: "bulletedList" },
84
+ { id: 6, txt: "numberedList" },
85
+ { id: 7, txt: "link" },
86
+ ]);
87
+
88
+ const itemsRef = useRef<[HTMLButtonElement] | []>([]);
89
+ const [currentFocusIndex, setCurrentFocusIndex] = useState(0);
90
+ const [toolbarInit, setToolbarInit] = useState(false);
91
+
92
+ useEffect(() => {
93
+ const index = `button-${currentFocusIndex}` as unknown as number;
94
+ const el = itemsRef.current[index];
95
+ if (el && toolbarInit) {
96
+ el.focus();
97
+ }
98
+ }, [currentFocusIndex, toolbarInit]);
99
+
100
+ const handleNav = useCallback(
101
+ (evt: KeyboardEvent<HTMLInputElement>) => {
102
+ const { key } = evt;
103
+
104
+ if (!toolbarInit) {
105
+ setCurrentFocusIndex(0);
106
+ setToolbarInit(true);
107
+ }
108
+
109
+ if (key === "ArrowLeft") {
110
+ evt.preventDefault();
111
+ setCurrentFocusIndex((index) => Math.max(0, index - 1));
112
+ } else if (key === "ArrowRight") {
113
+ evt.preventDefault();
114
+ setCurrentFocusIndex((index) => Math.min(items.length - 1, index + 1));
115
+ }
116
+ },
117
+ [items, setCurrentFocusIndex, setToolbarInit, toolbarInit]
118
+ );
119
+
120
+ const formatHeading = (level: HeadingTagType) => {
121
+ if (blockType === level) {
122
+ formatParagraph();
123
+ }
124
+
125
+ if (blockType !== level) {
126
+ editor.update(() => {
127
+ const selection = $getSelection();
128
+ if ($isRangeSelection(selection)) {
129
+ $wrapNodes(selection, () => $createHeadingNode(level));
130
+ }
131
+ });
132
+ }
133
+ };
134
+
135
+ const formatParagraph = () => {
136
+ if (blockType !== "paragraph") {
137
+ editor.update(() => {
138
+ const selection = $getSelection();
139
+
140
+ if ($isRangeSelection(selection)) {
141
+ $wrapNodes(selection, () => $createParagraphNode());
142
+ }
143
+ });
144
+ }
145
+ };
146
+
147
+ const formatBulletList = (evt: React.MouseEvent<HTMLButtonElement>) => {
148
+ evt.preventDefault();
149
+ if (blockType !== "bullet") {
150
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
151
+ return;
152
+ }
153
+ editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
154
+ };
155
+
156
+ const formatNumberedList = (evt: React.MouseEvent<HTMLButtonElement>) => {
157
+ evt.preventDefault();
158
+ if (blockType !== "number") {
159
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
160
+ return;
161
+ }
162
+ editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
163
+ };
164
+
165
+ const updateToolbar = useCallback(() => {
166
+ const selection = $getSelection();
167
+
168
+ if ($isRangeSelection(selection)) {
169
+ const anchorNode = selection.anchor.getNode();
170
+ const element =
171
+ anchorNode.getKey() === "root" ? anchorNode : anchorNode.getTopLevelElementOrThrow();
172
+ const elementKey = element.getKey();
173
+ const elementDOM = editor.getElementByKey(elementKey);
174
+ if (elementDOM !== null) {
175
+ setSelectedElementKey(elementKey);
176
+
177
+ const type = $isHeadingNode(element) ? element.getTag() : element.getType();
178
+ setBlockType(type);
179
+ }
180
+
181
+ // Update text format
182
+ setIsBold(selection.hasFormat("bold"));
183
+ setIsItalic(selection.hasFormat("italic"));
184
+
185
+ // Get current node and parent
186
+ const node = getSelectedNode(selection);
187
+ const parent = node.getParent();
188
+
189
+ // Update links
190
+ if ($isLinkNode(parent) || $isLinkNode(node)) {
191
+ setIsLink(true);
192
+ } else {
193
+ setIsLink(false);
194
+ }
195
+
196
+ if (elementDOM !== null) {
197
+ setSelectedElementKey(elementKey);
198
+ if ($isListNode(element)) {
199
+ const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode);
200
+ const type = parentList ? parentList.getListType() : element.getListType();
201
+ setBlockType(type);
202
+ } else {
203
+ const type = $isHeadingNode(element) ? element.getTag() : element.getType();
204
+ if (type in blockTypeToBlockName) {
205
+ setBlockType(type as keyof typeof blockTypeToBlockName);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }, [editor]);
211
+
212
+ useEffect(() => {
213
+ return mergeRegister(
214
+ editor.registerUpdateListener(({ editorState }) => {
215
+ editorState.read(() => {
216
+ updateToolbar();
217
+ });
218
+ }),
219
+ editor.registerCommand(
220
+ SELECTION_CHANGE_COMMAND,
221
+ () => {
222
+ updateToolbar();
223
+ return false;
224
+ },
225
+ LowPriority
226
+ )
227
+ );
228
+ }, [editor, updateToolbar]);
229
+
230
+ const { t } = useTranslation("form-builder");
231
+ const editorHasFocus = useEditorFocus();
232
+
233
+ return (
234
+ <>
235
+ <div
236
+ className="gc-toolbar-container"
237
+ role="toolbar"
238
+ aria-label={t("textFormatting")}
239
+ aria-controls={editorId}
240
+ onKeyDown={handleNav}
241
+ data-testid="toolbar"
242
+ >
243
+ <ToolTip text={t("tooltipFormatH2")}>
244
+ <button
245
+ tabIndex={currentFocusIndex == 0 ? 0 : -1}
246
+ ref={(el) => {
247
+ const index = "button-0" as unknown as number;
248
+ if (el && itemsRef.current) {
249
+ itemsRef.current[index] = el;
250
+ }
251
+ }}
252
+ onClick={() => {
253
+ formatHeading("h2");
254
+ }}
255
+ className={
256
+ "toolbar-item spaced " + (blockType === "h2" && editorHasFocus ? "active" : "")
257
+ }
258
+ aria-label={t("formatH2")}
259
+ aria-pressed={blockType === "h2"}
260
+ data-testid={`h2-button`}
261
+ >
262
+ <H2Icon />
263
+ </button>
264
+ </ToolTip>
265
+
266
+ <ToolTip text={t("tooltipFormatH3")}>
267
+ <button
268
+ tabIndex={currentFocusIndex == 1 ? 0 : -1}
269
+ ref={(el) => {
270
+ const index = "button-1" as unknown as number;
271
+ if (el && itemsRef.current) {
272
+ itemsRef.current[index] = el;
273
+ }
274
+ }}
275
+ onClick={() => {
276
+ formatHeading("h3");
277
+ }}
278
+ className={
279
+ "peer toolbar-item spaced " + (blockType === "h3" && editorHasFocus ? "active" : "")
280
+ }
281
+ aria-label={t("formatH3")}
282
+ aria-pressed={blockType === "h3"}
283
+ data-testid={`h3-button`}
284
+ >
285
+ <H3Icon />
286
+ </button>
287
+ </ToolTip>
288
+
289
+ <ToolTip text={t("tooltipFormatBold")}>
290
+ <button
291
+ tabIndex={currentFocusIndex == 2 ? 0 : -1}
292
+ ref={(el) => {
293
+ const index = "button-2" as unknown as number;
294
+ if (el && itemsRef.current) {
295
+ itemsRef.current[index] = el;
296
+ }
297
+ }}
298
+ onClick={() => {
299
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
300
+ }}
301
+ className={"peer toolbar-item " + (isBold && editorHasFocus ? "active" : "")}
302
+ aria-label={t("formatBold")}
303
+ aria-pressed={isBold}
304
+ data-testid={`bold-button`}
305
+ >
306
+ <BoldIcon />
307
+ </button>
308
+ </ToolTip>
309
+
310
+ <ToolTip text={t("tooltipFormatItalic")}>
311
+ <button
312
+ tabIndex={currentFocusIndex == 3 ? 0 : -1}
313
+ ref={(el) => {
314
+ const index = "button-3" as unknown as number;
315
+ if (el && itemsRef.current) {
316
+ itemsRef.current[index] = el;
317
+ }
318
+ }}
319
+ onClick={() => {
320
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
321
+ }}
322
+ className={"peer toolbar-item " + (isItalic && editorHasFocus ? "active" : "")}
323
+ aria-label={t("formatItalic")}
324
+ aria-pressed={isItalic}
325
+ data-testid={`italic-button`}
326
+ >
327
+ <ItalicIcon />
328
+ </button>
329
+ </ToolTip>
330
+
331
+ <ToolTip text={t("tooltipFormatBulletList")}>
332
+ <button
333
+ tabIndex={currentFocusIndex == 4 ? 0 : -1}
334
+ ref={(el) => {
335
+ const index = "button-4" as unknown as number;
336
+ if (el && itemsRef.current) {
337
+ itemsRef.current[index] = el;
338
+ }
339
+ }}
340
+ onClick={formatBulletList}
341
+ className={
342
+ "peer toolbar-item " + (blockType === "bullet" && editorHasFocus ? "active" : "")
343
+ }
344
+ aria-label={t("formatBulletList")}
345
+ aria-pressed={blockType === "bullet"}
346
+ data-testid={`bullet-list-button`}
347
+ >
348
+ <BulletListIcon />
349
+ </button>
350
+ </ToolTip>
351
+
352
+ <ToolTip text={t("tooltipFormatNumberedList")}>
353
+ <button
354
+ tabIndex={currentFocusIndex == 5 ? 0 : -1}
355
+ ref={(el) => {
356
+ const index = "button-5" as unknown as number;
357
+ if (el && itemsRef.current) {
358
+ itemsRef.current[index] = el;
359
+ }
360
+ }}
361
+ onClick={formatNumberedList}
362
+ className={
363
+ "peer toolbar-item " + (blockType === "number" && editorHasFocus ? "active" : "")
364
+ }
365
+ aria-label={t("formatNumberedList")}
366
+ aria-pressed={blockType === "number"}
367
+ data-testid={`numbered-list-button`}
368
+ >
369
+ <NumberedListIcon />
370
+ </button>
371
+ </ToolTip>
372
+
373
+ <ToolTip text={t("tooltipInsertLink")}>
374
+ <button
375
+ tabIndex={currentFocusIndex == 6 ? 0 : -1}
376
+ ref={(el) => {
377
+ const index = "button-6" as unknown as number;
378
+ if (el && itemsRef.current) {
379
+ itemsRef.current[index] = el;
380
+ }
381
+ }}
382
+ disabled={!isEditable}
383
+ onClick={insertLink}
384
+ className={"peer toolbar-item " + (isLink && editorHasFocus ? "active" : "")}
385
+ aria-label={t("insertLink")}
386
+ aria-pressed={isLink}
387
+ data-testid={`link-button`}
388
+ >
389
+ <LinkIcon />
390
+ </button>
391
+ </ToolTip>
392
+ </div>
393
+ </>
394
+ );
395
+ }
@@ -0,0 +1,24 @@
1
+ .gc-toolbar-container {
2
+ padding: 10px;
3
+ border-radius: 2px 2px 0 0;
4
+ background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
5
+ border-bottom: 1px solid #e9ecef;
6
+
7
+ &:focus-within {
8
+ margin: -2px;
9
+ border: 2px solid #015ecc;
10
+ }
11
+
12
+ button {
13
+ padding: 4px;
14
+ border: 1.5px solid transparent;
15
+ border-radius: 4px;
16
+ margin-right: 5px;
17
+ svg {
18
+ display: block;
19
+ }
20
+ &.active {
21
+ border: 1.5px solid #015ecc;
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+
9
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
10
+ import { TreeView } from "@lexical/react/LexicalTreeView";
11
+ import * as React from "react";
12
+
13
+ import type { JSX } from "react";
14
+
15
+ export default function TreeViewPlugin(): JSX.Element {
16
+ const [editor] = useLexicalComposerContext();
17
+ return (
18
+ <TreeView
19
+ viewClassName="tree-view-output"
20
+ timeTravelPanelClassName="debug-timetravel-panel"
21
+ timeTravelButtonClassName="debug-timetravel-button"
22
+ timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
23
+ timeTravelPanelButtonClassName="debug-timetravel-panel-button"
24
+ treeTypeButtonClassName=""
25
+ editor={editor}
26
+ />
27
+ );
28
+ }
package/src/styles.css ADDED
@@ -0,0 +1,11 @@
1
+ /*--------------------------------------------*
2
+ * Rich text editor
3
+ *--------------------------------------------*/
4
+
5
+ .editor-list-ul li {
6
+ margin-bottom: 0.25rem;
7
+ }
8
+
9
+ .gc-editor-container {
10
+ position: relative;
11
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+ import React from "react";
3
+ import { Editor } from "../Editor";
4
+
5
+ describe("<RichTextEditor />", () => {
6
+ it("Adds and styles text", () => {
7
+ // see: https://on.cypress.io/mounting-react
8
+ cy.mount(
9
+ <div className="form-builder">
10
+ <Editor content="" ariaLabel="AriaLabel" lang="en" />
11
+ </div>
12
+ );
13
+
14
+ // Add some strings to get formatted
15
+ cy.get('[id^="editor-"]').type(
16
+ `
17
+ H2 heading text
18
+ H3 heading text
19
+ Let's bold part of this sentence.
20
+ Let's italicize part of this sentence.
21
+ Part of his one will be a link
22
+ `
23
+ );
24
+
25
+ // Add H2 heading
26
+ cy.get('[id^="editor-"]').setSelection("H2 heading text");
27
+ cy.get('[data-testid="h2-button"]').first().click();
28
+ cy.get('[id^="editor-"] h2').first().contains("H2 heading text");
29
+ cy.get('[data-testid="h2-button"]').first().should("have.attr", "aria-pressed", "true");
30
+
31
+ // Add H3 heading
32
+ cy.get('[id^="editor-"]').setSelection("H3 heading text");
33
+ cy.get('[data-testid="h3-button"]').first().click();
34
+ cy.get('[id^="editor-"] h3').first().contains("H3 heading text");
35
+ cy.get('[data-testid="h3-button"]').first().should("have.attr", "aria-pressed", "true");
36
+
37
+ // Bold part of the text
38
+ cy.get('[id^="editor-"]').setSelection("bold part of this");
39
+ cy.get('[data-testid="bold-button"]').first().click();
40
+ cy.get('[id^="editor-"] strong').first().contains("bold part of this");
41
+ cy.get('[data-testid="bold-button"]').first().should("have.attr", "aria-pressed", "true");
42
+
43
+ // Italicize part of the text
44
+ cy.get('[id^="editor-"]').setSelection("italicize part of this");
45
+ cy.get('[data-testid="italic-button"]').first().click();
46
+ cy.get('[id^="editor-"] em').first().contains("italicize part of this");
47
+ cy.get('[data-testid="italic-button"]').first().should("have.attr", "aria-pressed", "true");
48
+
49
+ // Add a bullet list
50
+ cy.get('[id^="editor-"]').type("{moveToEnd}This is a bullet list item");
51
+ cy.get('[data-testid="bullet-list-button"]').first().click();
52
+
53
+ cy.get('[id^="editor-"]')
54
+ .setCursorAfter("This is a bullet list item")
55
+ .type("{enter}This is another bullet list item");
56
+ cy.get('[data-testid="bullet-list-button"]')
57
+ .first()
58
+ .should("have.attr", "aria-pressed", "true");
59
+ cy.get('[id^="editor-"] ul li').first().contains("This is a bullet list item");
60
+ cy.get('[id^="editor-"] ul li').last().contains("This is another bullet list item");
61
+
62
+ // Add a numbered list - two {enter}s are needed to escape the previous list
63
+ cy.get('[id^="editor-"]').type("{moveToEnd}{enter}{enter}This is a numbered list item");
64
+ cy.get('[data-testid="numbered-list-button"]').first().click();
65
+ cy.get('[data-testid="numbered-list-button"]')
66
+ .first()
67
+ .should("have.attr", "aria-pressed", "true");
68
+ cy.get('[id^="editor-"]')
69
+ .setCursorAfter("This is a numbered list item")
70
+ .type("{enter}This is another numbered list item");
71
+ cy.get('[data-testid="numbered-list-button"]')
72
+ .first()
73
+ .should("have.attr", "aria-pressed", "true");
74
+ cy.get('[id^="editor-"] ol li').first().contains("This is a numbered list item");
75
+ cy.get('[id^="editor-"] ol li').last().contains("This is another numbered list item");
76
+
77
+ // Add a link
78
+ cy.get('[id^="editor-"]').setSelection("will be a link");
79
+ cy.get('[data-testid="link-button"]').first().click();
80
+ cy.get('[data-testid="link-editor"]').first().click();
81
+ cy.get('[data-testid="link-editor"]').type("example.com{enter}{esc}");
82
+ cy.get('[id^="editor-"] a').first().contains("will be a link");
83
+ });
84
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from "react";
5
+ import { cleanup, screen, render, act, within } from "@testing-library/react";
6
+ import userEvent from "@testing-library/user-event";
7
+ // import { defaultStore as store, Providers } from "@lib/utils/form-builder/test-utils";
8
+ // import { RichTextEditor } from "../RichTextEditor";
9
+ import { Editor } from "../Editor";
10
+
11
+ const promise = Promise.resolve();
12
+
13
+ describe("Lexical Editor", () => {
14
+ afterEach(() => {
15
+ cleanup();
16
+ });
17
+
18
+ it("Renders the Lexical Editor", async () => {
19
+ //
20
+ const rendered = render(
21
+ <Editor ariaLabel="AriaLabel" ariaDescribedBy="AriaDescribedBy" id="editor-test" content="Here is some test content" />
22
+ );
23
+
24
+ await act(async () => {
25
+ await promise;
26
+ });
27
+
28
+ const contentArea = rendered.container.querySelector('[id^="editor-"]');
29
+ const toolbar = screen.getByTestId("toolbar");
30
+ const [h2, h3, bold, italic, bulletList, numberedList, link] =
31
+ within(toolbar).getAllByRole("button");
32
+
33
+ const toolbarButtons = within(toolbar).getAllByRole("button");
34
+
35
+ expect(h2).toHaveAttribute("tabindex", "0");
36
+ expect(h3).toHaveAttribute("tabindex", "-1");
37
+ expect(bold).toHaveAttribute("tabindex", "-1");
38
+ expect(italic).toHaveAttribute("tabindex", "-1");
39
+ expect(bulletList).toHaveAttribute("tabindex", "-1");
40
+ expect(numberedList).toHaveAttribute("tabindex", "-1");
41
+ expect(link).toHaveAttribute("tabindex", "-1");
42
+
43
+ // Toolbar has aria-controls attribute
44
+ expect(toolbar).toHaveAttribute("aria-controls", contentArea.id);
45
+
46
+ // Toolbar contains 7 formatting buttons
47
+ expect(toolbarButtons).toHaveLength(7);
48
+
49
+ // Content area has default content and attributes
50
+ expect(contentArea).toHaveAttribute("aria-label", "AriaLabel");
51
+ expect(contentArea).toContainHTML("Here is some test content");
52
+ expect(contentArea).toHaveAttribute("contenteditable", "true");
53
+ expect(contentArea).toHaveAttribute("role", "textbox");
54
+ expect(contentArea).toHaveAttribute("spellcheck", "true");
55
+ expect(contentArea).toHaveAttribute("data-lexical-editor", "true");
56
+ });
57
+
58
+ it("can keyboard navigate the RichTextEditor", async () => {
59
+ render(
60
+ <div><Editor ariaLabel="AriaLabel" ariaDescribedBy="AriaDescribedBy" content="Here is some test content" /></div>
61
+ );
62
+
63
+ await act(async () => {
64
+ await promise;
65
+ });
66
+
67
+ const toolbar = screen.getByTestId("toolbar");
68
+
69
+ const [h2, h3, bold, italic, bulletList, numberedList, link] =
70
+ within(toolbar).getAllByRole("button");
71
+
72
+ // expect(document.body).toHaveFocus();
73
+
74
+ // tab into toolbar
75
+ await userEvent.tab();
76
+ expect(h2).toHaveFocus();
77
+
78
+ // tab back out of toolbar
79
+ await userEvent.tab({ shift: true });
80
+ expect(document.body).toHaveFocus();
81
+
82
+ // tab back into toolbar
83
+ await userEvent.tab();
84
+ await userEvent.keyboard("{arrowright}");
85
+ expect(h3).toHaveFocus();
86
+
87
+ await userEvent.keyboard("{arrowright}");
88
+ expect(bold).toHaveFocus();
89
+
90
+ await userEvent.keyboard("{arrowright}");
91
+ expect(italic).toHaveFocus();
92
+
93
+ await userEvent.keyboard("{arrowright}");
94
+ expect(bulletList).toHaveFocus();
95
+
96
+ await userEvent.keyboard("{arrowright}");
97
+ expect(numberedList).toHaveFocus();
98
+
99
+ await userEvent.keyboard("{arrowright}");
100
+ expect(link).toHaveFocus();
101
+
102
+ await userEvent.keyboard("{arrowleft}");
103
+ expect(numberedList).toHaveFocus();
104
+
105
+ // tab back out of toolbar
106
+ await userEvent.tab({ shift: true });
107
+ expect(document.body).toHaveFocus();
108
+
109
+ // tab back into toolbar
110
+ await userEvent.tab();
111
+ expect(numberedList).toHaveFocus();
112
+ });
113
+ });