@squiz/formatted-text-editor 1.12.0-alpha.9 → 1.12.1-alpha.0

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.
Files changed (162) hide show
  1. package/.eslintrc.json +34 -0
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +18 -56
  4. package/build.js +21 -0
  5. package/cypress/e2e/bold.spec.cy.ts +18 -0
  6. package/cypress/global.d.ts +9 -0
  7. package/cypress/support/commands.ts +130 -0
  8. package/cypress/support/e2e.ts +20 -0
  9. package/cypress/tsconfig.json +8 -0
  10. package/cypress.config.ts +7 -0
  11. package/demo/App.tsx +39 -0
  12. package/demo/index.html +13 -0
  13. package/demo/index.scss +40 -0
  14. package/demo/main.tsx +10 -0
  15. package/demo/public/favicon-dxp.svg +3 -0
  16. package/demo/vite-env.d.ts +1 -0
  17. package/file-transformer.js +1 -0
  18. package/jest.bootstrap.ts +3 -0
  19. package/jest.config.ts +30 -0
  20. package/lib/Editor/Editor.d.ts +4 -2
  21. package/lib/Editor/Editor.js +11 -14
  22. package/lib/EditorToolbar/FloatingToolbar.d.ts +1 -0
  23. package/lib/EditorToolbar/FloatingToolbar.js +31 -0
  24. package/lib/EditorToolbar/Toolbar.d.ts +1 -0
  25. package/lib/EditorToolbar/Toolbar.js +25 -0
  26. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.d.ts +10 -0
  27. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +23 -0
  28. package/lib/EditorToolbar/Tools/Link/LinkButton.d.ts +5 -0
  29. package/lib/EditorToolbar/Tools/Link/LinkButton.js +34 -0
  30. package/lib/EditorToolbar/Tools/Link/LinkModal.d.ts +8 -0
  31. package/lib/EditorToolbar/Tools/Link/LinkModal.js +14 -0
  32. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.d.ts +2 -0
  33. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +16 -0
  34. package/lib/EditorToolbar/Tools/Redo/RedoButton.d.ts +2 -0
  35. package/lib/EditorToolbar/Tools/Redo/RedoButton.js +16 -0
  36. package/lib/EditorToolbar/Tools/TextType/Heading/HeadingButton.d.ts +5 -0
  37. package/lib/EditorToolbar/Tools/TextType/Heading/HeadingButton.js +32 -0
  38. package/lib/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.d.ts +2 -0
  39. package/lib/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.js +16 -0
  40. package/lib/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.d.ts +2 -0
  41. package/lib/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.js +16 -0
  42. package/lib/EditorToolbar/Tools/TextType/TextTypeDropdown.d.ts +2 -0
  43. package/lib/EditorToolbar/Tools/TextType/TextTypeDropdown.js +35 -0
  44. package/lib/EditorToolbar/Tools/Undo/UndoButton.d.ts +2 -0
  45. package/lib/EditorToolbar/Tools/Undo/UndoButton.js +16 -0
  46. package/lib/EditorToolbar/index.d.ts +2 -0
  47. package/lib/EditorToolbar/index.js +2 -0
  48. package/lib/Extensions/Extensions.d.ts +4 -0
  49. package/lib/Extensions/Extensions.js +20 -0
  50. package/lib/Extensions/LinkExtension/LinkExtension.d.ts +16 -0
  51. package/lib/Extensions/LinkExtension/LinkExtension.js +91 -0
  52. package/lib/Extensions/PreformattedExtension/PreformattedExtension.d.ts +10 -0
  53. package/lib/Extensions/PreformattedExtension/PreformattedExtension.js +46 -0
  54. package/lib/FormattedTextEditor.d.ts +2 -2
  55. package/lib/FormattedTextEditor.js +1 -6
  56. package/lib/hooks/index.d.ts +1 -0
  57. package/lib/hooks/index.js +1 -0
  58. package/lib/hooks/useExtensionNames.d.ts +1 -0
  59. package/lib/hooks/useExtensionNames.js +12 -0
  60. package/lib/index.css +786 -3689
  61. package/lib/ui/Inputs/Select/Select.d.ts +12 -0
  62. package/lib/ui/Inputs/Select/Select.js +23 -0
  63. package/lib/ui/Inputs/Text/TextInput.d.ts +4 -0
  64. package/lib/ui/Inputs/Text/TextInput.js +7 -0
  65. package/lib/ui/Modal/FormModal.d.ts +5 -0
  66. package/lib/ui/Modal/FormModal.js +11 -0
  67. package/lib/ui/Modal/Modal.d.ts +10 -0
  68. package/lib/ui/Modal/Modal.js +48 -0
  69. package/lib/ui/ToolbarButton/ToolbarButton.d.ts +1 -1
  70. package/lib/ui/ToolbarButton/ToolbarButton.js +1 -1
  71. package/lib/ui/ToolbarDropdown/ToolbarDropdown.d.ts +6 -0
  72. package/lib/ui/ToolbarDropdown/ToolbarDropdown.js +20 -0
  73. package/lib/ui/ToolbarDropdownButton/ToolbarDropdownButton.d.ts +9 -0
  74. package/lib/ui/ToolbarDropdownButton/ToolbarDropdownButton.js +8 -0
  75. package/lib/utils/createToolbarPositioner.d.ts +18 -0
  76. package/lib/utils/createToolbarPositioner.js +81 -0
  77. package/lib/utils/getCursorRect.d.ts +2 -0
  78. package/lib/utils/getCursorRect.js +3 -0
  79. package/package.json +22 -13
  80. package/postcss.config.js +12 -0
  81. package/src/Editor/Editor.mock.tsx +43 -0
  82. package/src/Editor/Editor.spec.tsx +254 -0
  83. package/src/Editor/Editor.tsx +46 -0
  84. package/src/Editor/_editor.scss +82 -0
  85. package/src/EditorToolbar/FloatingToolbar.spec.tsx +30 -0
  86. package/src/EditorToolbar/FloatingToolbar.tsx +40 -0
  87. package/src/EditorToolbar/Toolbar.tsx +33 -0
  88. package/src/EditorToolbar/Tools/Bold/BoldButton.spec.tsx +19 -0
  89. package/src/EditorToolbar/Tools/Bold/BoldButton.tsx +30 -0
  90. package/src/EditorToolbar/Tools/Italic/ItalicButton.spec.tsx +19 -0
  91. package/src/EditorToolbar/Tools/Italic/ItalicButton.tsx +30 -0
  92. package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +30 -0
  93. package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +48 -0
  94. package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +277 -0
  95. package/src/EditorToolbar/Tools/Link/LinkButton.tsx +56 -0
  96. package/src/EditorToolbar/Tools/Link/LinkModal.tsx +29 -0
  97. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +46 -0
  98. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +27 -0
  99. package/src/EditorToolbar/Tools/Redo/RedoButton.spec.tsx +59 -0
  100. package/src/EditorToolbar/Tools/Redo/RedoButton.tsx +30 -0
  101. package/src/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.spec.tsx +39 -0
  102. package/src/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.tsx +31 -0
  103. package/src/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.spec.tsx +39 -0
  104. package/src/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.tsx +31 -0
  105. package/src/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.spec.tsx +39 -0
  106. package/src/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.tsx +31 -0
  107. package/src/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.spec.tsx +39 -0
  108. package/src/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.tsx +31 -0
  109. package/src/EditorToolbar/Tools/TextAlign/TextAlignButtons.tsx +21 -0
  110. package/src/EditorToolbar/Tools/TextType/Heading/HeadingButton.spec.tsx +56 -0
  111. package/src/EditorToolbar/Tools/TextType/Heading/HeadingButton.tsx +52 -0
  112. package/src/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.spec.tsx +30 -0
  113. package/src/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.tsx +25 -0
  114. package/src/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.spec.tsx +47 -0
  115. package/src/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.tsx +30 -0
  116. package/src/EditorToolbar/Tools/TextType/TextTypeDropdown.spec.tsx +51 -0
  117. package/src/EditorToolbar/Tools/TextType/TextTypeDropdown.tsx +44 -0
  118. package/src/EditorToolbar/Tools/Underline/Underline.spec.tsx +19 -0
  119. package/src/EditorToolbar/Tools/Underline/UnderlineButton.tsx +30 -0
  120. package/src/EditorToolbar/Tools/Undo/UndoButton.spec.tsx +49 -0
  121. package/src/EditorToolbar/Tools/Undo/UndoButton.tsx +30 -0
  122. package/src/EditorToolbar/_floating-toolbar.scss +4 -0
  123. package/src/EditorToolbar/_toolbar.scss +16 -0
  124. package/src/EditorToolbar/index.ts +2 -0
  125. package/src/Extensions/Extensions.ts +29 -0
  126. package/src/Extensions/LinkExtension/LinkExtension.ts +116 -0
  127. package/src/Extensions/PreformattedExtension/PreformattedExtension.ts +50 -0
  128. package/src/FormattedTextEditor.spec.tsx +10 -0
  129. package/src/FormattedTextEditor.tsx +3 -0
  130. package/src/hooks/index.ts +1 -0
  131. package/src/hooks/useExtensionNames.ts +15 -0
  132. package/src/index.scss +19 -0
  133. package/src/index.ts +3 -0
  134. package/src/ui/Inputs/Select/Select.spec.tsx +30 -0
  135. package/src/ui/Inputs/Select/Select.tsx +66 -0
  136. package/src/ui/Inputs/Text/TextInput.spec.tsx +43 -0
  137. package/src/ui/Inputs/Text/TextInput.tsx +20 -0
  138. package/src/ui/Modal/FormModal.spec.tsx +20 -0
  139. package/src/ui/Modal/FormModal.tsx +17 -0
  140. package/src/ui/Modal/Modal.spec.tsx +113 -0
  141. package/src/ui/Modal/Modal.tsx +97 -0
  142. package/src/ui/Modal/_modal.scss +24 -0
  143. package/src/ui/ToolbarButton/ToolbarButton.tsx +26 -0
  144. package/src/ui/ToolbarButton/_toolbar-button.scss +17 -0
  145. package/src/ui/ToolbarDropdown/ToolbarDropdown.spec.tsx +78 -0
  146. package/src/ui/ToolbarDropdown/ToolbarDropdown.tsx +42 -0
  147. package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +32 -0
  148. package/src/ui/ToolbarDropdownButton/ToolbarDropdownButton.spec.tsx +48 -0
  149. package/src/ui/ToolbarDropdownButton/ToolbarDropdownButton.tsx +29 -0
  150. package/src/ui/ToolbarDropdownButton/_toolbar-dropdown-button.scss +14 -0
  151. package/src/ui/_buttons.scss +19 -0
  152. package/src/ui/_forms.scss +16 -0
  153. package/src/utils/createToolbarPositioner.ts +115 -0
  154. package/src/utils/getCursorRect.ts +5 -0
  155. package/tailwind.config.cjs +83 -0
  156. package/tests/index.ts +2 -0
  157. package/tests/renderWithEditor.tsx +110 -0
  158. package/tests/select.tsx +16 -0
  159. package/tsconfig.json +22 -0
  160. package/vite.config.ts +19 -0
  161. package/lib/EditorToolbar/EditorToolbar.d.ts +0 -7
  162. package/lib/EditorToolbar/EditorToolbar.js +0 -21
@@ -0,0 +1,29 @@
1
+ import {
2
+ BoldExtension,
3
+ HeadingExtension,
4
+ ItalicExtension,
5
+ NodeFormattingExtension,
6
+ ParagraphExtension,
7
+ UnderlineExtension,
8
+ HistoryExtension,
9
+ } from 'remirror/extensions';
10
+ import { PreformattedExtension } from './PreformattedExtension/PreformattedExtension';
11
+ import { LinkExtension } from './LinkExtension/LinkExtension';
12
+
13
+ export const Extensions = () => [
14
+ new BoldExtension(),
15
+ new HeadingExtension(),
16
+ new ItalicExtension(),
17
+ new NodeFormattingExtension(),
18
+ new ParagraphExtension(),
19
+ new PreformattedExtension(),
20
+ new UnderlineExtension(),
21
+ new HistoryExtension(),
22
+ new LinkExtension({
23
+ supportedTargets: [
24
+ // '_self' is the browser default and will be used when encountering a link with a
25
+ // different target is encountered.
26
+ '_blank',
27
+ ],
28
+ }),
29
+ ];
@@ -0,0 +1,116 @@
1
+ import { ApplySchemaAttributes, getMarkRanges, isElementDomNode, MarkExtensionSpec, MarkSpecOverride } from 'remirror';
2
+ import { getTextSelection, getMarkRange, KeyBindingProps, updateMark, removeMark } from '@remirror/core';
3
+ import { CommandFunction } from '@remirror/pm';
4
+ import { LinkAttributes as RemirrorLinkAttributes, LinkExtension as RemirrorLinkExtension } from 'remirror/extensions';
5
+
6
+ export type UpdateLinkOptions = LinkAttributes & {
7
+ text: string;
8
+ };
9
+
10
+ export type LinkAttributes = RemirrorLinkAttributes & {
11
+ title?: string;
12
+ };
13
+
14
+ export class LinkExtension extends RemirrorLinkExtension {
15
+ createMarkSpec(extra: ApplySchemaAttributes, override: MarkSpecOverride): MarkExtensionSpec {
16
+ const spec = super.createMarkSpec(extra, override);
17
+
18
+ return {
19
+ ...spec,
20
+ excludes: undefined,
21
+ attrs: {
22
+ ...spec.attrs,
23
+ title: { default: undefined },
24
+ },
25
+ parseDOM: [
26
+ {
27
+ tag: 'a[href]',
28
+ getAttrs: (node) => {
29
+ if (!isElementDomNode(node)) {
30
+ return false;
31
+ }
32
+
33
+ return {
34
+ ...extra.parse(node),
35
+ auto: false,
36
+ href: node.getAttribute('href'),
37
+ target: node.getAttribute('target'),
38
+ title: node.getAttribute('title'),
39
+ };
40
+ },
41
+ },
42
+ ],
43
+ };
44
+ }
45
+
46
+ shortcut({ tr }: KeyBindingProps): boolean {
47
+ // override parent implementation to allow a link to be inserted without requiring a text selection first.
48
+ const { from, to, $from } = tr.selection;
49
+ const mark = getMarkRange($from, this.type);
50
+ const selectedText = tr.doc.textBetween(from, to);
51
+
52
+ this.options.onShortcut({
53
+ activeLink: mark ? { attrs: mark.mark.attrs as LinkAttributes, from: mark.from, to: mark.to } : undefined,
54
+ selectedText,
55
+ from,
56
+ to,
57
+ });
58
+
59
+ return true;
60
+ }
61
+
62
+ selectLink(): CommandFunction {
63
+ // parent implementation selects only the link text, this mimics closer to Google Docs where
64
+ // the select text is widened to include the link but retains non-link selected text as well.
65
+ return (props) => {
66
+ const { tr } = props;
67
+ const ranges = getMarkRanges(tr.selection, this.type);
68
+
69
+ if (ranges.length === 0) {
70
+ return false;
71
+ }
72
+
73
+ // work out the start position of the first link and end position of the last link in the selection.
74
+ const from = Math.min(tr.selection.from, ...ranges.map((range) => range.from));
75
+ const to = Math.max(tr.selection.to, ...ranges.map((range) => range.to));
76
+
77
+ // don't need to widen the selection, return early.
78
+ if (tr.selection.from === from && tr.selection.to === to) {
79
+ return false;
80
+ }
81
+
82
+ // widen the selection to make sure the full link is included.
83
+ this.store.commands.selectText.original({ from, to })(props);
84
+ return true;
85
+ };
86
+ }
87
+
88
+ updateLink(options: UpdateLinkOptions): CommandFunction {
89
+ const { text, ...attrs } = options;
90
+
91
+ return (props) => {
92
+ const { tr, dispatch, view } = props;
93
+ const range = { from: tr.selection.from, to: tr.selection.to };
94
+ const selectedText = tr.doc.textBetween(range.from, range.to);
95
+
96
+ if (text !== selectedText) {
97
+ // update the text in the editor if it was updated, update the range to cover the length of the new text.
98
+ dispatch?.(tr.insertText(text));
99
+ range.to = range.from + text.length;
100
+ }
101
+
102
+ // apply the link, or remove it if no URL was provided.
103
+ if (attrs.href.length > 0) {
104
+ updateMark({ type: this.type, attrs, range })(props);
105
+ } else {
106
+ removeMark({ type: this.type, range })(props);
107
+ }
108
+
109
+ // move the cursor to the end of the link and re-focus the editor.
110
+ dispatch?.(tr.setSelection(getTextSelection({ from: range.to, to: range.to }, tr.doc)));
111
+ view?.focus();
112
+
113
+ return true;
114
+ };
115
+ }
116
+ }
@@ -0,0 +1,50 @@
1
+ import {
2
+ ApplySchemaAttributes,
3
+ command,
4
+ CommandFunction,
5
+ extension,
6
+ ExtensionTag,
7
+ NodeExtension,
8
+ NodeExtensionSpec,
9
+ NodeSpecOverride,
10
+ ProsemirrorNode,
11
+ toggleBlockItem,
12
+ } from '@remirror/core';
13
+
14
+ @extension({})
15
+ export class PreformattedExtension extends NodeExtension {
16
+ get name() {
17
+ return 'preformatted' as const;
18
+ }
19
+
20
+ createTags() {
21
+ return [ExtensionTag.Block, ExtensionTag.TextBlock, ExtensionTag.FormattingNode];
22
+ }
23
+
24
+ createNodeSpec(extra: ApplySchemaAttributes, override: NodeSpecOverride): NodeExtensionSpec {
25
+ return {
26
+ content: 'inline*',
27
+ defining: true,
28
+ draggable: false,
29
+ ...override,
30
+ attrs: {
31
+ ...extra.defaults(),
32
+ },
33
+ parseDOM: [...(override.parseDOM ?? [])],
34
+ toDOM: (node: ProsemirrorNode) => {
35
+ return [`pre`, extra.dom(node), 0];
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Toggle the <pre> for the current block.
42
+ */
43
+ @command()
44
+ togglePreformatted(): CommandFunction {
45
+ return toggleBlockItem({
46
+ type: this.type,
47
+ toggleType: 'paragraph',
48
+ });
49
+ }
50
+ }
@@ -0,0 +1,10 @@
1
+ import { render } from '@testing-library/react';
2
+ import { FormattedTextEditor } from './';
3
+ import React from 'react';
4
+
5
+ describe('<FormattedTextEditor />', () => {
6
+ it('should render "<FormattedTextEditor />" component', () => {
7
+ const { baseElement } = render(<FormattedTextEditor />);
8
+ expect(baseElement).toBeTruthy();
9
+ });
10
+ });
@@ -0,0 +1,3 @@
1
+ import Editor from './Editor/Editor';
2
+
3
+ export default Editor;
@@ -0,0 +1 @@
1
+ export * from './useExtensionNames';
@@ -0,0 +1,15 @@
1
+ import { useRemirrorContext } from '@remirror/react';
2
+ import { useMemo } from 'react';
3
+
4
+ export const useExtensionNames = () => {
5
+ const { manager } = useRemirrorContext();
6
+ return useMemo(() => {
7
+ const extensionNames: Record<string, true> = {};
8
+
9
+ manager.extensions.forEach((extension) => {
10
+ extensionNames[extension.name] = true;
11
+ });
12
+
13
+ return extensionNames;
14
+ }, [manager]);
15
+ };
package/src/index.scss ADDED
@@ -0,0 +1,19 @@
1
+ /* Tailwind base styles, but scoped to the editor */
2
+ @import 'tailwindcss/base';
3
+ @import 'tailwindcss/components';
4
+ @import 'tailwindcss/utilities';
5
+
6
+ /* Global */
7
+ @import './ui/forms';
8
+ @import './ui/buttons';
9
+
10
+ /* Components */
11
+ @import './Editor/editor';
12
+ @import './EditorToolbar/toolbar';
13
+ @import './EditorToolbar/floating-toolbar';
14
+
15
+ @import './ui/ToolbarButton/toolbar-button';
16
+ @import './ui/ToolbarDropdown/toolbar-dropdown';
17
+ @import './ui/ToolbarDropdownButton/toolbar-dropdown-button';
18
+
19
+ @import './ui/Modal/modal';
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import FormattedTextEditor from './FormattedTextEditor';
2
+
3
+ export { FormattedTextEditor };
@@ -0,0 +1,30 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { Select } from './Select';
5
+
6
+ describe('Select field', () => {
7
+ const selectOptions = {
8
+ chicken: { label: 'chicken' },
9
+ egg: { label: 'Egg' },
10
+ };
11
+
12
+ it('Renders the label', () => {
13
+ render(<Select name="select-field" label="Which came first?" value="Chicken" options={selectOptions} />);
14
+ // Check that the supplied label renders
15
+ const inputLabel = screen.getByLabelText('Which came first?');
16
+ expect(inputLabel).toBeInTheDocument();
17
+ });
18
+
19
+ it('Renders the default value', () => {
20
+ render(<Select name="select-field" label="Which came first?" value="Chicken" options={selectOptions} />);
21
+ // Check that default value supplied renders
22
+ expect(screen.getByDisplayValue('Chicken')).toBeInTheDocument();
23
+ });
24
+
25
+ it('Renders an empty state when no value is provided', () => {
26
+ render(<Select name="select-field" label="Which came first?" options={selectOptions} />);
27
+ // nbsp should be rendered so input maintains a consistent size when nothing is selected.
28
+ expect(screen.getByLabelText('Which came first?')).toContainHTML('&nbsp;');
29
+ });
30
+ });
@@ -0,0 +1,66 @@
1
+ import { Listbox } from '@headlessui/react';
2
+ import React, { useState } from 'react';
3
+ import clsx from 'clsx';
4
+ import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded';
5
+ import ExpandLessRoundedIcon from '@mui/icons-material/ExpandLessRounded';
6
+
7
+ export type SelectOptions = Record<string, SelectOption>;
8
+ export type SelectOption = {
9
+ label: string;
10
+ };
11
+
12
+ export type SelectProps = {
13
+ name: string;
14
+ label?: string;
15
+ value?: string;
16
+ options: SelectOptions;
17
+ onChange?: (value: string) => void;
18
+ };
19
+
20
+ export const Select = ({ name, label, value, onChange, options }: SelectProps) => {
21
+ const [selectedOption, setSelectedOptions] = useState(value ? options[value] : null);
22
+ const handleChange = (value: string): void => {
23
+ setSelectedOptions(options[value]);
24
+ onChange?.(value);
25
+ };
26
+
27
+ return (
28
+ <Listbox value={value} name={name} aria-labelledby={name} onChange={handleChange}>
29
+ {({ open }) => (
30
+ <>
31
+ {label && <Listbox.Label className="squiz-fte-form-label">{label}</Listbox.Label>}
32
+ <div className="relative text-md text-gray-800">
33
+ <Listbox.Button className="w-full cursor-default rounded border-2 border-gray-300 bg-white py-2 pl-3 pr-10 focus:border-blue-300 focus:outline-none">
34
+ <span className="flex items-center">
35
+ <span className="block truncate">{selectedOption?.label}&nbsp;</span>
36
+ </span>
37
+ <span className="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2">
38
+ {!open ? (
39
+ <ExpandMoreRoundedIcon className="text-gray-500" aria-hidden="true" />
40
+ ) : (
41
+ <ExpandLessRoundedIcon className="text-gray-500" aria-hidden="true" />
42
+ )}
43
+ </span>
44
+ </Listbox.Button>
45
+ <Listbox.Options className="absolute z-10 mt-1 w-full overflow-auto rounded border-2 border-gray-300 bg-white">
46
+ {Object.entries(options).map(([key, option]) => (
47
+ <Listbox.Option
48
+ key={key}
49
+ value={key}
50
+ className={({ active }) =>
51
+ clsx(
52
+ active ? 'bg-gray-100' : 'bg-white',
53
+ 'relative cursor-default select-none flex hover:bg-gray-100 py-2 px-3',
54
+ )
55
+ }
56
+ >
57
+ <span className="block truncate">{option.label}</span>
58
+ </Listbox.Option>
59
+ ))}
60
+ </Listbox.Options>
61
+ </div>
62
+ </>
63
+ )}
64
+ </Listbox>
65
+ );
66
+ };
@@ -0,0 +1,43 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { TextInput } from './TextInput';
5
+
6
+ describe('Text input', () => {
7
+ const mockOnChange = jest.fn();
8
+
9
+ const TextInputComponent = () => {
10
+ return <TextInput name="text-input" defaultValue="Water" label="Text input" onChange={mockOnChange} />;
11
+ };
12
+
13
+ it('Renders the label', () => {
14
+ render(<TextInputComponent />);
15
+ // Check that the supplied label renders
16
+ const inputLabel = screen.getByLabelText('Text input');
17
+ expect(inputLabel).toBeInTheDocument();
18
+ });
19
+
20
+ it('Renders the default value', () => {
21
+ render(<TextInputComponent />);
22
+ // Check that default value supplied renders
23
+ expect(screen.getByDisplayValue('Water')).toBeInTheDocument();
24
+ });
25
+
26
+ it('Changes the value when new value entered', () => {
27
+ render(<TextInputComponent />);
28
+ const input = screen.getByLabelText('Text input') as HTMLInputElement;
29
+ // Check that default value supplied renders
30
+ expect(input.value).toBe('Water');
31
+ fireEvent.change(input, { target: { value: 'Wine' } });
32
+ expect(input.value).toBe('Wine');
33
+ });
34
+
35
+ it('Fires the change function when new value entered', () => {
36
+ render(<TextInputComponent />);
37
+ const input = screen.getByLabelText('Text input') as HTMLInputElement;
38
+ // Check that default value supplied renders
39
+ expect(input.value).toBe('Water');
40
+ fireEvent.change(input, { target: { value: 'Wine' } });
41
+ expect(mockOnChange).toHaveBeenCalled();
42
+ });
43
+ });
@@ -0,0 +1,20 @@
1
+ import React, { ForwardedRef, forwardRef, InputHTMLAttributes } from 'react';
2
+
3
+ type TextInputProps = InputHTMLAttributes<HTMLInputElement> & {
4
+ label?: string;
5
+ };
6
+
7
+ const TextInputInternal = ({ name, label, ...rest }: TextInputProps, ref: ForwardedRef<HTMLInputElement>) => {
8
+ return (
9
+ <>
10
+ {label && (
11
+ <label htmlFor={name} className="squiz-fte-form-label">
12
+ {label}
13
+ </label>
14
+ )}
15
+ <input ref={ref} id={name} name={name} type="text" className="squiz-fte-form-control" {...rest} />
16
+ </>
17
+ );
18
+ };
19
+
20
+ export const TextInput = forwardRef(TextInputInternal);
@@ -0,0 +1,20 @@
1
+ import '@testing-library/jest-dom';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import React from 'react';
4
+ import FormModal from './FormModal';
5
+
6
+ describe('FormModal', () => {
7
+ it('Triggers form submit handler when modal is submitted', () => {
8
+ const handleSubmit = jest.fn();
9
+
10
+ render(
11
+ <FormModal title="Modal title" onCancel={jest.fn()}>
12
+ <form onSubmit={handleSubmit}></form>
13
+ </FormModal>,
14
+ );
15
+
16
+ fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
17
+
18
+ expect(handleSubmit).toHaveBeenCalled();
19
+ });
20
+ });
@@ -0,0 +1,17 @@
1
+ import React, { createRef, ReactElement } from 'react';
2
+ import Modal, { ModalProps } from './Modal';
3
+
4
+ type FormModalProps = Omit<ModalProps, 'onSubmit'>;
5
+
6
+ const FormModal = (props: FormModalProps): ReactElement => {
7
+ const ref = createRef<HTMLDivElement>();
8
+ const handleSubmit = () => {
9
+ const form = ref.current?.querySelector('form');
10
+
11
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
12
+ };
13
+
14
+ return <Modal ref={ref} {...props} onSubmit={handleSubmit} />;
15
+ };
16
+
17
+ export default FormModal;
@@ -0,0 +1,113 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import React from 'react';
4
+ import Modal from './Modal';
5
+
6
+ describe('Modal', () => {
7
+ const mockOnCancel = jest.fn();
8
+ const mockOnSubmit = jest.fn();
9
+
10
+ const ModalComponent = () => {
11
+ return (
12
+ <Modal title="Modal title" onCancel={mockOnCancel}>
13
+ <div>I am a child in the modal</div>
14
+ </Modal>
15
+ );
16
+ };
17
+
18
+ it('Renders the modal title', () => {
19
+ render(<ModalComponent />);
20
+ // Check that the modal heading displays
21
+ const modalHeading = screen.getByRole('heading', {
22
+ name: /Modal title/i,
23
+ });
24
+ expect(modalHeading).toBeInTheDocument();
25
+ });
26
+
27
+ it('Renders the child', () => {
28
+ render(<ModalComponent />);
29
+ // Check that the modal heading displays
30
+ const modalChild = screen.getByText(/I am a child in the modal/i);
31
+ expect(modalChild).toBeInTheDocument();
32
+ });
33
+
34
+ it('Renders the cancel button', () => {
35
+ render(<ModalComponent />);
36
+ // Check that the cancel button renders
37
+ const cancelButton = screen.getByRole('button', { name: 'Cancel' });
38
+ expect(cancelButton).toBeInTheDocument();
39
+ // check that firing the cancel button fires the cancel function
40
+ fireEvent.click(cancelButton);
41
+ expect(mockOnCancel).toHaveBeenCalled();
42
+ });
43
+
44
+ it('Does not render the submit button if there is no submit function supplied', () => {
45
+ render(<ModalComponent />);
46
+ // Check that the submit button does not render
47
+ const submitButton = screen.queryByRole('button', { name: 'Apply' });
48
+ expect(submitButton).not.toBeInTheDocument();
49
+ });
50
+
51
+ it('Renders the submit button if there is a submit function supplied', () => {
52
+ render(
53
+ <Modal title="Modal title" onCancel={mockOnCancel} onSubmit={mockOnSubmit}>
54
+ <div>I am a child in the modal</div>
55
+ </Modal>,
56
+ );
57
+ // Check that the submit button renders
58
+ const submitButton = screen.getByRole('button', { name: 'Apply' });
59
+ expect(submitButton).toBeInTheDocument();
60
+ });
61
+
62
+ it('Checks that the submit function fires if you click on the submit button', () => {
63
+ render(
64
+ <Modal title="Modal title" onCancel={mockOnCancel} onSubmit={mockOnSubmit}>
65
+ <div>I am a child in the modal</div>
66
+ </Modal>,
67
+ );
68
+ // Check that the submit button renders
69
+ const submitButton = screen.getByRole('button', { name: 'Apply' });
70
+ expect(submitButton).toBeInTheDocument();
71
+ // check that firing the submit button fires the submit function
72
+ fireEvent.click(submitButton);
73
+ expect(mockOnSubmit).toHaveBeenCalled();
74
+ });
75
+
76
+ it('Calls the onSubmit handler when the enter key is pressed', () => {
77
+ render(
78
+ <Modal title="Modal title" onCancel={mockOnCancel} onSubmit={mockOnSubmit}>
79
+ <div>Modal content</div>
80
+ </Modal>,
81
+ );
82
+
83
+ fireEvent.keyUp(screen.getByText('Modal content'), { key: 'Enter' });
84
+
85
+ expect(mockOnSubmit).toHaveBeenCalled();
86
+ });
87
+
88
+ it('Calls the onCancel handler when the escape key is pressed', () => {
89
+ render(
90
+ <Modal title="Modal title" onCancel={mockOnCancel} onSubmit={mockOnSubmit}>
91
+ <div>Modal content</div>
92
+ </Modal>,
93
+ );
94
+
95
+ fireEvent.keyUp(screen.getByText('Modal content'), { key: 'Escape' });
96
+
97
+ expect(mockOnCancel).toHaveBeenCalled();
98
+ });
99
+
100
+ it('Auto-focuses on the first non-hidden input on mount', () => {
101
+ render(
102
+ <Modal title="Modal title" onCancel={mockOnCancel} onSubmit={mockOnSubmit}>
103
+ <>
104
+ <input id="hidden-input" type="hidden" />
105
+ <label htmlFor="my-input">My input</label>
106
+ <input id="my-input" type="text" />
107
+ </>
108
+ </Modal>,
109
+ );
110
+
111
+ expect(screen.getByLabelText('My input')).toHaveFocus();
112
+ });
113
+ });
@@ -0,0 +1,97 @@
1
+ import React, { ForwardedRef, forwardRef, ReactElement, useEffect, useMemo } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
4
+ import { FocusTrap } from '@mui/base';
5
+
6
+ export type ModalProps = {
7
+ title: string;
8
+ children: ReactElement;
9
+ onCancel: () => void;
10
+ onSubmit?: () => void;
11
+ className?: string;
12
+ };
13
+
14
+ const Modal = (
15
+ { children, title, onCancel, onSubmit, className }: ModalProps,
16
+ ref: ForwardedRef<HTMLDivElement>,
17
+ ): ReactElement => {
18
+ const container = useMemo(() => {
19
+ const element = document.createElement('div');
20
+ element.classList.add('squiz-fte-scope');
21
+ return element;
22
+ }, []);
23
+ const keydown = (e: { key: string }) => {
24
+ if (e.key === 'Escape') {
25
+ onCancel();
26
+ }
27
+ if (e.key === 'Enter') {
28
+ onSubmit && onSubmit();
29
+ }
30
+ };
31
+
32
+ // register key listeners for Enter/Escape on key up so the editor doesn't handle the event as well
33
+ useEffect(() => {
34
+ window.addEventListener('keyup', keydown);
35
+ return () => window.removeEventListener('keyup', keydown);
36
+ }, []);
37
+
38
+ // add/remove the modal container from the DOM and focus on the first input
39
+ useEffect(() => {
40
+ const firstInput = container.querySelector('input:not([type=hidden])') as HTMLInputElement;
41
+
42
+ document.body.appendChild(container);
43
+ firstInput?.focus();
44
+
45
+ return () => {
46
+ document.body.removeChild(container);
47
+ };
48
+ }, [container]);
49
+
50
+ return createPortal(
51
+ <>
52
+ <FocusTrap open>
53
+ <div ref={ref} className={`squiz-fte-modal-wrapper ${className ? className : ''}`} tabIndex={-1}>
54
+ <div className="w-modal-sm my-6 mx-auto">
55
+ <div className="squiz-fte-modal">
56
+ <div className="squiz-fte-modal-header p-6 pb-2">
57
+ <h2 className="font-semibold text-gray-900 text-heading-2">{title}</h2>
58
+ <button
59
+ className="ml-auto -mr-3 -mt-3 bg-transparent border-0 text-gray-600 font-semibold outline-none focus:outline-none hover:text-color-gray-800"
60
+ onClick={onCancel}
61
+ aria-label="Close"
62
+ >
63
+ <CloseRoundedIcon />
64
+ </button>
65
+ </div>
66
+ <div className="squiz-fte-modal-content">{children}</div>
67
+ <div className="squiz-fte-modal-footer p-6 pt-3">
68
+ <button
69
+ className="squiz-fte-modal-footer__button bg-gray-200 text-gray-700 mr-2 hover:bg-gray-300"
70
+ type="button"
71
+ onClick={onCancel}
72
+ aria-label="Cancel"
73
+ >
74
+ Cancel
75
+ </button>
76
+ {onSubmit && (
77
+ <button
78
+ className="squiz-fte-modal-footer__button bg-blue-300 text-white hover:bg-blue-400"
79
+ type="button"
80
+ onClick={onSubmit}
81
+ aria-label="Apply"
82
+ >
83
+ Apply
84
+ </button>
85
+ )}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </FocusTrap>
91
+ <div className="opacity-25 fixed inset-0 z-40 bg-black"></div>
92
+ </>,
93
+ container,
94
+ );
95
+ };
96
+
97
+ export default forwardRef(Modal);