@gcforms/editor 1.0.6 → 1.0.7

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.7] - 2025-08-25
9
+
10
+ ### Changed
11
+
12
+ - Update indent and outdent to have buttons and new shortcuts
13
+
8
14
  ## [1.0.6] - 2025-07-21
9
15
 
10
16
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcforms/editor",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "author": "Canadian Digital Service",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/src/Editor.tsx CHANGED
@@ -14,7 +14,6 @@ import { ListPlugin } from "@lexical/react/LexicalListPlugin";
14
14
  import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
15
15
 
16
16
  import ContentEditable from "./ui/ContentEditable";
17
- import TabControlPlugin from "./plugins/TabControlPlugin";
18
17
  import ToolbarPlugin from "./plugins/ToolbarPlugin";
19
18
  import TreeViewPlugin from "./plugins/TreeViewPlugin";
20
19
  import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentPlugin";
@@ -116,7 +115,6 @@ export const Editor = ({
116
115
  />
117
116
  <ListPlugin />
118
117
  <LinkPlugin />
119
- <TabControlPlugin />
120
118
  <ListMaxIndentLevelPlugin maxDepth={5} />
121
119
 
122
120
  {floatingAnchorElem && (
package/src/i18n/en.json CHANGED
@@ -18,5 +18,9 @@
18
18
  "saveLink": "Save link",
19
19
  "editLink": "Edit link",
20
20
  "removeLink": "Remove link",
21
- "pressEnterToEditLink": "Press Enter to edit link"
21
+ "pressEnterToEditLink": "Press Enter to edit link",
22
+ "tooltipIndent": "Indent",
23
+ "tooltipOutdent": "Outdent",
24
+ "indent": "Indent",
25
+ "outdent": "Outdent"
22
26
  }
package/src/i18n/fr.json CHANGED
@@ -18,5 +18,9 @@
18
18
  "saveLink": "Enregistrer le lien",
19
19
  "editLink": "Modifier le lien",
20
20
  "removeLink": "Supprimer le lien",
21
- "pressEnterToEditLink": "Appuyez sur Entrée pour modifier le lien"
21
+ "pressEnterToEditLink": "Appuyez sur Entrée pour modifier le lien",
22
+ "tooltipIndent": "Indentation",
23
+ "tooltipOutdent": "Suppression d'indentation",
24
+ "indent": "Indentation",
25
+ "outdent": "Suppression d'indentation"
22
26
  }
@@ -0,0 +1,21 @@
1
+ export const IndentIcon = ({
2
+ className,
3
+ title = "Indent",
4
+ }: {
5
+ className?: string;
6
+ title?: string;
7
+ }) => (
8
+ <svg
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width="24"
11
+ height="24"
12
+ viewBox="0 -960 960 960"
13
+ className={className}
14
+ focusable="false"
15
+ aria-hidden={title ? false : true}
16
+ role={title ? "img" : "presentation"}
17
+ >
18
+ {title && <title>{title}</title>}
19
+ <path d="M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm0 440v-320l160 160-160 160Z" />
20
+ </svg>
21
+ );
@@ -0,0 +1,21 @@
1
+ export const OutdentIcon = ({
2
+ className,
3
+ title = "Outdent",
4
+ }: {
5
+ className?: string;
6
+ title?: string;
7
+ }) => (
8
+ <svg
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ width="24"
11
+ height="24"
12
+ viewBox="0 -960 960 960"
13
+ className={className}
14
+ focusable="false"
15
+ aria-hidden={title ? false : true}
16
+ role={title ? "img" : "presentation"}
17
+ >
18
+ {title && <title>{title}</title>}
19
+ <path d="M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm160 440L120-480l160-160v320Z" />
20
+ </svg>
21
+ );
@@ -15,7 +15,9 @@ import { useToolbarState } from "../../context/ToolbarContext";
15
15
  import {
16
16
  formatBulletList,
17
17
  formatHeading,
18
+ formatIndent,
18
19
  formatNumberedList,
20
+ formatOutdent,
19
21
  formatParagraph,
20
22
  } from "../ToolbarPlugin/utils";
21
23
  import {
@@ -23,7 +25,9 @@ import {
23
25
  isFormatHeading,
24
26
  isFormatNumberedList,
25
27
  isFormatParagraph,
28
+ isIndent,
26
29
  isInsertLink,
30
+ isOutdent,
27
31
  } from "./shortcuts";
28
32
  import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
29
33
 
@@ -62,6 +66,12 @@ export default function ShortcutsPlugin({
62
66
  setIsLinkEditMode(!toolbarState.isLink);
63
67
 
64
68
  editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
69
+ } else if (isIndent(event)) {
70
+ event.preventDefault();
71
+ formatIndent(editor);
72
+ } else if (isOutdent(event)) {
73
+ event.preventDefault();
74
+ formatOutdent(editor);
65
75
  }
66
76
 
67
77
  return false;
@@ -11,19 +11,32 @@ import { IS_APPLE } from "@lexical/utils";
11
11
  //disable eslint sorting rule for quick reference to shortcuts
12
12
  export const SHORTCUTS = Object.freeze({
13
13
  // (Ctrl|⌘) + (Alt|Option) + <key> shortcuts
14
- NORMAL: IS_APPLE ? "⌘+Opt+0" : "Ctrl+Alt+0",
15
- HEADING2: IS_APPLE ? "⌘+Opt+2" : "Ctrl+Alt+2",
16
- HEADING3: IS_APPLE ? "⌘+Opt+3" : "Ctrl+Alt+3",
17
- BULLET_LIST: IS_APPLE ? "⌘+Opt+4" : "Ctrl+Alt+4",
18
- NUMBERED_LIST: IS_APPLE ? "⌘+Opt+5" : "Ctrl+Alt+5",
14
+ NORMAL: IS_APPLE ? "⌘ + Opt + 0" : "Ctrl + Alt + 0",
15
+ HEADING2: IS_APPLE ? "⌘ + Opt + 2" : "Ctrl + Alt + 2",
16
+ HEADING3: IS_APPLE ? "⌘ + Opt + 3" : "Ctrl + Alt + 3",
17
+ BULLET_LIST: IS_APPLE ? "⌘ + Opt + 4" : "Ctrl + Alt + 4",
18
+ NUMBERED_LIST: IS_APPLE ? "⌘ + Opt + 5" : "Ctrl + Alt + 5",
19
19
 
20
20
  // (Ctrl|⌘) + Shift + <key> shortcuts
21
21
  //
22
22
 
23
23
  // (Ctrl|⌘) + <key> shortcuts
24
- BOLD: IS_APPLE ? "⌘+B" : "Ctrl+B",
25
- ITALIC: IS_APPLE ? "⌘+I" : "Ctrl+I",
26
- INSERT_LINK: IS_APPLE ? "⌘+K" : "Ctrl+K",
24
+ BOLD: IS_APPLE ? "⌘ + B" : "Ctrl + B",
25
+ ITALIC: IS_APPLE ? "⌘ + I" : "Ctrl + I",
26
+ INSERT_LINK: IS_APPLE ? "⌘ + K" : "Ctrl + K",
27
+
28
+ // Why the "⌘ + ]/[" was chosen:
29
+ // "⌘+Opt+]" prevents overriding the key "Opt+[" “curly quotes” on macOS
30
+ // "⌘+Opt+]" prevents overriding the key "⌘+[" to go back in browser history on macOS
31
+ // But "⌘+Opt+]" is cumbersome and not very discoverable. Making an expection
32
+ // to override the default browser history keys for better usability in the editor.
33
+ //
34
+ // Note: screenreaders will ignore special characeters like brackets so there is a
35
+ // version for for sited users and alt version for screenreader users.
36
+ INDENT: IS_APPLE ? "⌘ + ]" : "Ctrl + ]",
37
+ INDENT_ALT: IS_APPLE ? "⌘ + Right bracket" : "Ctrl + Right bracket",
38
+ OUTDENT: IS_APPLE ? "⌘ + [" : "Ctrl + [",
39
+ OUTDENT_ALT: IS_APPLE ? "⌘ + Left bracket" : "Ctrl + Left bracket",
27
40
  });
28
41
 
29
42
  export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean {
@@ -74,3 +87,15 @@ export function isInsertLink(event: KeyboardEvent): boolean {
74
87
  const { code, shiftKey, altKey, metaKey, ctrlKey } = event;
75
88
  return code === "KeyK" && !shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey);
76
89
  }
90
+
91
+ export const isIndent = (event: KeyboardEvent) => {
92
+ const { code, shiftKey, altKey, metaKey, ctrlKey } = event;
93
+ // ⌘ + ] (macOS) or Ctrl + ] (Win/Linux)
94
+ return code === "BracketRight" && !shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey);
95
+ };
96
+
97
+ export function isOutdent(event: KeyboardEvent): boolean {
98
+ const { code, shiftKey, altKey, metaKey, ctrlKey } = event;
99
+ // ⌘ + [ (macOS) or Ctrl + [ (Win/Linux)
100
+ return code === "BracketLeft" && !shiftKey && !altKey && controlOrMeta(metaKey, ctrlKey);
101
+ }
@@ -29,11 +29,19 @@ import { BulletListIcon } from "../../icons/BulletListIcon";
29
29
  import { NumberedListIcon } from "../../icons/NumberedListIcon";
30
30
  import { LinkIcon } from "../../icons/LinkIcon";
31
31
  import { useTranslation } from "../../hooks/useTranslation";
32
- import { formatBulletList, formatHeading, formatNumberedList } from "./utils";
32
+ import {
33
+ formatBulletList,
34
+ formatHeading,
35
+ formatIndent,
36
+ formatNumberedList,
37
+ formatOutdent,
38
+ } from "./utils";
33
39
  import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
34
40
  import "./styles.css";
35
41
  import { SHORTCUTS } from "../ShortcutsPlugin/shortcuts";
36
42
  import { blockTypeToBlockName, useToolbarState } from "../../context/ToolbarContext";
43
+ import { IndentIcon } from "../../icons/IndentIcon";
44
+ import { OutdentIcon } from "../../icons/OutdentIcon";
37
45
 
38
46
  export default function ToolbarPlugin({
39
47
  editorId,
@@ -69,6 +77,8 @@ export default function ToolbarPlugin({
69
77
  { id: 5, txt: "bulletedList" },
70
78
  { id: 6, txt: "numberedList" },
71
79
  { id: 7, txt: "link" },
80
+ { id: 8, txt: "indent" },
81
+ { id: 9, txt: "outdent" },
72
82
  ]);
73
83
 
74
84
  const itemsRef = useRef<[HTMLButtonElement] | []>([]);
@@ -210,7 +220,7 @@ export default function ToolbarPlugin({
210
220
  onKeyDown={handleNav}
211
221
  data-testid="toolbar"
212
222
  >
213
- <ToolTip text={t("tooltipFormatH2") + ` (${SHORTCUTS.HEADING2})`}>
223
+ <ToolTip text={t("tooltipFormatH2")} element={keyboardShortcut(SHORTCUTS.HEADING2)}>
214
224
  <button
215
225
  tabIndex={currentFocusIndex == 0 ? 0 : -1}
216
226
  ref={(el) => {
@@ -233,7 +243,7 @@ export default function ToolbarPlugin({
233
243
  </button>
234
244
  </ToolTip>
235
245
 
236
- <ToolTip text={t("tooltipFormatH3") + ` (${SHORTCUTS.HEADING3})`}>
246
+ <ToolTip text={t("tooltipFormatH3")} element={keyboardShortcut(SHORTCUTS.HEADING3)}>
237
247
  <button
238
248
  tabIndex={currentFocusIndex == 1 ? 0 : -1}
239
249
  ref={(el) => {
@@ -256,7 +266,7 @@ export default function ToolbarPlugin({
256
266
  </button>
257
267
  </ToolTip>
258
268
 
259
- <ToolTip text={t("tooltipFormatBold") + ` (${SHORTCUTS.BOLD})`}>
269
+ <ToolTip text={t("tooltipFormatBold")} element={keyboardShortcut(SHORTCUTS.BOLD)}>
260
270
  <button
261
271
  tabIndex={currentFocusIndex == 2 ? 0 : -1}
262
272
  ref={(el) => {
@@ -277,7 +287,7 @@ export default function ToolbarPlugin({
277
287
  </button>
278
288
  </ToolTip>
279
289
 
280
- <ToolTip text={t("tooltipFormatItalic") + ` (${SHORTCUTS.ITALIC})`}>
290
+ <ToolTip text={t("tooltipFormatItalic")} element={keyboardShortcut(SHORTCUTS.ITALIC)}>
281
291
  <button
282
292
  tabIndex={currentFocusIndex == 3 ? 0 : -1}
283
293
  ref={(el) => {
@@ -298,7 +308,10 @@ export default function ToolbarPlugin({
298
308
  </button>
299
309
  </ToolTip>
300
310
 
301
- <ToolTip text={t("tooltipFormatBulletList") + ` (${SHORTCUTS.BULLET_LIST})`}>
311
+ <ToolTip
312
+ text={t("tooltipFormatBulletList")}
313
+ element={keyboardShortcut(SHORTCUTS.BULLET_LIST)}
314
+ >
302
315
  <button
303
316
  tabIndex={currentFocusIndex == 4 ? 0 : -1}
304
317
  ref={(el) => {
@@ -320,7 +333,10 @@ export default function ToolbarPlugin({
320
333
  </button>
321
334
  </ToolTip>
322
335
 
323
- <ToolTip text={t("tooltipFormatNumberedList") + ` (${SHORTCUTS.NUMBERED_LIST})`}>
336
+ <ToolTip
337
+ text={t("tooltipFormatNumberedList")}
338
+ element={keyboardShortcut(SHORTCUTS.NUMBERED_LIST)}
339
+ >
324
340
  <button
325
341
  tabIndex={currentFocusIndex == 5 ? 0 : -1}
326
342
  ref={(el) => {
@@ -342,7 +358,7 @@ export default function ToolbarPlugin({
342
358
  </button>
343
359
  </ToolTip>
344
360
 
345
- <ToolTip text={t("tooltipInsertLink") + ` (${SHORTCUTS.INSERT_LINK})`}>
361
+ <ToolTip text={t("tooltipInsertLink")} element={keyboardShortcut(SHORTCUTS.INSERT_LINK)}>
346
362
  <button
347
363
  tabIndex={currentFocusIndex == 6 ? 0 : -1}
348
364
  ref={(el) => {
@@ -361,7 +377,57 @@ export default function ToolbarPlugin({
361
377
  <LinkIcon />
362
378
  </button>
363
379
  </ToolTip>
380
+
381
+ <ToolTip
382
+ text={t("tooltipIndent")}
383
+ element={keyboardShortcut(SHORTCUTS.INDENT, SHORTCUTS.INDENT_ALT)}
384
+ >
385
+ <button
386
+ tabIndex={currentFocusIndex == 7 ? 0 : -1}
387
+ ref={(el) => {
388
+ const index = "button-7" as unknown as number;
389
+ if (el && itemsRef.current) {
390
+ itemsRef.current[index] = el;
391
+ }
392
+ }}
393
+ onClick={() => formatIndent(editor)}
394
+ className={"toolbar-item"}
395
+ aria-label={t("indent")}
396
+ data-testid="indent-button"
397
+ >
398
+ <IndentIcon />
399
+ </button>
400
+ </ToolTip>
401
+
402
+ <ToolTip
403
+ text={t("tooltipOutdent")}
404
+ element={keyboardShortcut(SHORTCUTS.OUTDENT, SHORTCUTS.OUTDENT_ALT)}
405
+ >
406
+ <button
407
+ tabIndex={currentFocusIndex == 8 ? 0 : -1}
408
+ ref={(el) => {
409
+ const index = "button-8" as unknown as number;
410
+ if (el && itemsRef.current) {
411
+ itemsRef.current[index] = el;
412
+ }
413
+ }}
414
+ onClick={() => formatOutdent(editor)}
415
+ className={"toolbar-item"}
416
+ aria-label={t("outdent")}
417
+ data-testid="outdent-button"
418
+ >
419
+ <OutdentIcon />
420
+ </button>
421
+ </ToolTip>
364
422
  </div>
365
423
  </>
366
424
  );
367
425
  }
426
+
427
+ const keyboardShortcut = (text: string, ariaLabel?: string) => {
428
+ return (
429
+ <div>
430
+ <kbd {...(ariaLabel && { "aria-label": ariaLabel })}>{text}</kbd>
431
+ </div>
432
+ );
433
+ };
@@ -29,6 +29,7 @@ import {
29
29
  $isTextNode,
30
30
  LexicalEditor,
31
31
  } from "lexical";
32
+ import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from "lexical";
32
33
 
33
34
  export const formatParagraph = (editor: LexicalEditor) => {
34
35
  editor.update(() => {
@@ -170,3 +171,11 @@ export const clearFormatting = (editor: LexicalEditor) => {
170
171
  }
171
172
  });
172
173
  };
174
+
175
+ export const formatIndent = (editor: LexicalEditor) => {
176
+ editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
177
+ };
178
+
179
+ export const formatOutdent = (editor: LexicalEditor) => {
180
+ editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
181
+ };
@@ -82,5 +82,23 @@ describe("<RichTextEditor />", () => {
82
82
  );
83
83
  cy.get('[id^="editor-"] a').first().contains("will be a link");
84
84
  cy.get('[id^="editor-"] a').first().should("have.attr", "href", "https://example.com");
85
+
86
+ // Add a new numbered list to test the indent and outdent buttons
87
+ cy.get('[id^="editor-"]').type(
88
+ "{moveToEnd}{enter}{enter}{enter}This is a numbered list item 2"
89
+ );
90
+ cy.get('[data-testid="numbered-list-button"]').first().click();
91
+ cy.get('[id^="editor-"]')
92
+ .setCursorAfter("This is a numbered list item 2")
93
+ .type("{enter}This is another numbered list item 2");
94
+ cy.get('[id^="editor-"] ol li').last().contains("This is another numbered list item 2");
95
+ // Indent the last list item
96
+ cy.get('[data-testid="indent-button"]').click();
97
+ cy.get('[id^="editor-"] ol li').eq(2).contains("This is a numbered list item 2");
98
+ cy.get('[id^="editor-"] ol li ol li').last().contains("This is another numbered list item 2");
99
+ // Outdent the last list item (revert)
100
+ cy.get('[data-testid="outdent-button"]').click();
101
+ cy.get('[id^="editor-"] ol li').eq(2).contains("This is a numbered list item 2");
102
+ cy.get('[id^="editor-"] ol li').last().contains("This is another numbered list item 2");
85
103
  });
86
104
  });
@@ -27,7 +27,7 @@ describe("Lexical Editor", () => {
27
27
 
28
28
  const contentArea = rendered.container.querySelector('[id^="editor-"]');
29
29
  const toolbar = screen.getByTestId("toolbar");
30
- const [h2, h3, bold, italic, bulletList, numberedList, link] =
30
+ const [h2, h3, bold, italic, bulletList, numberedList, link, indent, outdent] =
31
31
  within(toolbar).getAllByRole("button");
32
32
 
33
33
  const toolbarButtons = within(toolbar).getAllByRole("button");
@@ -39,12 +39,14 @@ describe("Lexical Editor", () => {
39
39
  expect(bulletList).toHaveAttribute("tabindex", "-1");
40
40
  expect(numberedList).toHaveAttribute("tabindex", "-1");
41
41
  expect(link).toHaveAttribute("tabindex", "-1");
42
+ expect(indent).toHaveAttribute("tabindex", "-1");
43
+ expect(outdent).toHaveAttribute("tabindex", "-1");
42
44
 
43
45
  // Toolbar has aria-controls attribute
44
46
  expect(toolbar).toHaveAttribute("aria-controls", contentArea.id);
45
47
 
46
- // Toolbar contains 7 formatting buttons
47
- expect(toolbarButtons).toHaveLength(7);
48
+ // Toolbar contains 9 formatting buttons
49
+ expect(toolbarButtons).toHaveLength(9);
48
50
 
49
51
  // Content area has default content and attributes
50
52
  expect(contentArea).toHaveAttribute("aria-label", "AriaLabel");
@@ -2,7 +2,15 @@
2
2
  import React, { useId, Children } from "react";
3
3
  import "./ToolTip.css";
4
4
 
5
- export const ToolTip = ({ children, text }: { children: React.ReactElement; text: string }) => {
5
+ export const ToolTip = ({
6
+ children,
7
+ text,
8
+ element = null,
9
+ }: {
10
+ children: React.ReactElement;
11
+ text: string;
12
+ element?: React.JSX.Element | null;
13
+ }) => {
6
14
  const id = `tooltip-${useId()}`;
7
15
 
8
16
  return (
@@ -18,6 +26,7 @@ export const ToolTip = ({ children, text }: { children: React.ReactElement; text
18
26
  })}
19
27
  <span id={id} className="gc-tooltip">
20
28
  {text}
29
+ {element}
21
30
  </span>
22
31
  </span>
23
32
  );
@@ -1,28 +0,0 @@
1
- import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2
- import {
3
- COMMAND_PRIORITY_EDITOR,
4
- INDENT_CONTENT_COMMAND,
5
- KEY_TAB_COMMAND,
6
- OUTDENT_CONTENT_COMMAND,
7
- } from "lexical";
8
- import { useEffect } from "react";
9
-
10
- export default function TabControlPlugin() {
11
- const [editor] = useLexicalComposerContext();
12
-
13
- useEffect(() => {
14
- return editor.registerCommand(
15
- KEY_TAB_COMMAND,
16
- (payload) => {
17
- const event: KeyboardEvent = payload;
18
- event.preventDefault();
19
- return editor.dispatchCommand(
20
- event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,
21
- undefined
22
- );
23
- },
24
- COMMAND_PRIORITY_EDITOR
25
- );
26
- }, [editor]);
27
- return null;
28
- }