@squiz/formatted-text-editor 1.12.0-alpha.8 → 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.
- package/.eslintrc.json +34 -0
- package/CHANGELOG.md +48 -0
- package/README.md +2 -3
- package/build.js +21 -0
- package/cypress/e2e/bold.spec.cy.ts +18 -0
- package/cypress/global.d.ts +9 -0
- package/cypress/support/commands.ts +130 -0
- package/cypress/support/e2e.ts +20 -0
- package/cypress/tsconfig.json +8 -0
- package/cypress.config.ts +7 -0
- package/demo/App.tsx +39 -0
- package/demo/index.html +13 -0
- package/demo/index.scss +40 -0
- package/demo/main.tsx +10 -0
- package/demo/public/favicon-dxp.svg +3 -0
- package/demo/vite-env.d.ts +1 -0
- package/file-transformer.js +1 -0
- package/jest.bootstrap.ts +3 -0
- package/jest.config.ts +30 -0
- package/lib/Editor/Editor.d.ts +4 -2
- package/lib/Editor/Editor.js +11 -14
- package/lib/EditorToolbar/FloatingToolbar.d.ts +1 -0
- package/lib/EditorToolbar/FloatingToolbar.js +31 -0
- package/lib/EditorToolbar/Toolbar.d.ts +1 -0
- package/lib/EditorToolbar/Toolbar.js +25 -0
- package/lib/EditorToolbar/Tools/Link/Form/LinkForm.d.ts +10 -0
- package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +23 -0
- package/lib/EditorToolbar/Tools/Link/LinkButton.d.ts +5 -0
- package/lib/EditorToolbar/Tools/Link/LinkButton.js +34 -0
- package/lib/EditorToolbar/Tools/Link/LinkModal.d.ts +8 -0
- package/lib/EditorToolbar/Tools/Link/LinkModal.js +14 -0
- package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.d.ts +2 -0
- package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +16 -0
- package/lib/EditorToolbar/Tools/Redo/RedoButton.d.ts +2 -0
- package/lib/EditorToolbar/Tools/Redo/RedoButton.js +16 -0
- package/lib/EditorToolbar/Tools/TextAlign/TextAlignButtons.js +4 -1
- package/lib/EditorToolbar/Tools/TextType/Heading/HeadingButton.d.ts +5 -0
- package/lib/EditorToolbar/Tools/TextType/Heading/HeadingButton.js +32 -0
- package/lib/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.d.ts +2 -0
- package/lib/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.js +16 -0
- package/lib/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.d.ts +2 -0
- package/lib/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.js +16 -0
- package/lib/EditorToolbar/Tools/TextType/TextTypeDropdown.d.ts +2 -0
- package/lib/EditorToolbar/Tools/TextType/TextTypeDropdown.js +35 -0
- package/lib/EditorToolbar/Tools/Undo/UndoButton.d.ts +2 -0
- package/lib/EditorToolbar/Tools/Undo/UndoButton.js +16 -0
- package/lib/EditorToolbar/index.d.ts +2 -0
- package/lib/EditorToolbar/index.js +2 -0
- package/lib/Extensions/Extensions.d.ts +4 -0
- package/lib/Extensions/Extensions.js +20 -0
- package/lib/Extensions/LinkExtension/LinkExtension.d.ts +16 -0
- package/lib/Extensions/LinkExtension/LinkExtension.js +91 -0
- package/lib/Extensions/PreformattedExtension/PreformattedExtension.d.ts +10 -0
- package/lib/Extensions/PreformattedExtension/PreformattedExtension.js +46 -0
- package/lib/FormattedTextEditor.d.ts +2 -2
- package/lib/FormattedTextEditor.js +1 -6
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useExtensionNames.d.ts +1 -0
- package/lib/hooks/useExtensionNames.js +12 -0
- package/lib/index.css +787 -3686
- package/lib/ui/Inputs/Select/Select.d.ts +12 -0
- package/lib/ui/Inputs/Select/Select.js +23 -0
- package/lib/ui/Inputs/Text/TextInput.d.ts +4 -0
- package/lib/ui/Inputs/Text/TextInput.js +7 -0
- package/lib/ui/Modal/FormModal.d.ts +5 -0
- package/lib/ui/Modal/FormModal.js +11 -0
- package/lib/ui/Modal/Modal.d.ts +10 -0
- package/lib/ui/Modal/Modal.js +48 -0
- package/lib/ui/ToolbarButton/ToolbarButton.d.ts +1 -1
- package/lib/ui/ToolbarButton/ToolbarButton.js +1 -1
- package/lib/ui/ToolbarDropdown/ToolbarDropdown.d.ts +6 -0
- package/lib/ui/ToolbarDropdown/ToolbarDropdown.js +20 -0
- package/lib/ui/ToolbarDropdownButton/ToolbarDropdownButton.d.ts +9 -0
- package/lib/ui/ToolbarDropdownButton/ToolbarDropdownButton.js +8 -0
- package/lib/utils/createToolbarPositioner.d.ts +18 -0
- package/lib/utils/createToolbarPositioner.js +81 -0
- package/lib/utils/getCursorRect.d.ts +2 -0
- package/lib/utils/getCursorRect.js +3 -0
- package/package.json +22 -13
- package/postcss.config.js +12 -0
- package/src/Editor/Editor.mock.tsx +43 -0
- package/src/Editor/Editor.spec.tsx +254 -0
- package/src/Editor/Editor.tsx +46 -0
- package/src/Editor/_editor.scss +82 -0
- package/src/EditorToolbar/FloatingToolbar.spec.tsx +30 -0
- package/src/EditorToolbar/FloatingToolbar.tsx +40 -0
- package/src/EditorToolbar/Toolbar.tsx +33 -0
- package/src/EditorToolbar/Tools/Bold/BoldButton.spec.tsx +19 -0
- package/src/EditorToolbar/Tools/Bold/BoldButton.tsx +30 -0
- package/src/EditorToolbar/Tools/Italic/ItalicButton.spec.tsx +19 -0
- package/src/EditorToolbar/Tools/Italic/ItalicButton.tsx +30 -0
- package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +30 -0
- package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +48 -0
- package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +277 -0
- package/src/EditorToolbar/Tools/Link/LinkButton.tsx +56 -0
- package/src/EditorToolbar/Tools/Link/LinkModal.tsx +29 -0
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +46 -0
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +27 -0
- package/src/EditorToolbar/Tools/Redo/RedoButton.spec.tsx +59 -0
- package/src/EditorToolbar/Tools/Redo/RedoButton.tsx +30 -0
- package/src/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.spec.tsx +39 -0
- package/src/EditorToolbar/Tools/TextAlign/CenterAlign/CenterAlignButton.tsx +31 -0
- package/src/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.spec.tsx +39 -0
- package/src/EditorToolbar/Tools/TextAlign/JustifyAlign/JustifyAlignButton.tsx +31 -0
- package/src/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.spec.tsx +39 -0
- package/src/EditorToolbar/Tools/TextAlign/LeftAlign/LeftAlignButton.tsx +31 -0
- package/src/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.spec.tsx +39 -0
- package/src/EditorToolbar/Tools/TextAlign/RightAlign/RightAlignButton.tsx +31 -0
- package/src/EditorToolbar/Tools/TextAlign/TextAlignButtons.tsx +21 -0
- package/src/EditorToolbar/Tools/TextType/Heading/HeadingButton.spec.tsx +56 -0
- package/src/EditorToolbar/Tools/TextType/Heading/HeadingButton.tsx +52 -0
- package/src/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.spec.tsx +30 -0
- package/src/EditorToolbar/Tools/TextType/Paragraph/ParagraphButton.tsx +25 -0
- package/src/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.spec.tsx +47 -0
- package/src/EditorToolbar/Tools/TextType/Preformatted/PreformattedButton.tsx +30 -0
- package/src/EditorToolbar/Tools/TextType/TextTypeDropdown.spec.tsx +51 -0
- package/src/EditorToolbar/Tools/TextType/TextTypeDropdown.tsx +44 -0
- package/src/EditorToolbar/Tools/Underline/Underline.spec.tsx +19 -0
- package/src/EditorToolbar/Tools/Underline/UnderlineButton.tsx +30 -0
- package/src/EditorToolbar/Tools/Undo/UndoButton.spec.tsx +49 -0
- package/src/EditorToolbar/Tools/Undo/UndoButton.tsx +30 -0
- package/src/EditorToolbar/_floating-toolbar.scss +4 -0
- package/src/EditorToolbar/_toolbar.scss +16 -0
- package/src/EditorToolbar/index.ts +2 -0
- package/src/Extensions/Extensions.ts +29 -0
- package/src/Extensions/LinkExtension/LinkExtension.ts +116 -0
- package/src/Extensions/PreformattedExtension/PreformattedExtension.ts +50 -0
- package/src/FormattedTextEditor.spec.tsx +10 -0
- package/src/FormattedTextEditor.tsx +3 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useExtensionNames.ts +15 -0
- package/src/index.scss +19 -0
- package/src/index.ts +3 -0
- package/src/ui/Inputs/Select/Select.spec.tsx +30 -0
- package/src/ui/Inputs/Select/Select.tsx +66 -0
- package/src/ui/Inputs/Text/TextInput.spec.tsx +43 -0
- package/src/ui/Inputs/Text/TextInput.tsx +20 -0
- package/src/ui/Modal/FormModal.spec.tsx +20 -0
- package/src/ui/Modal/FormModal.tsx +17 -0
- package/src/ui/Modal/Modal.spec.tsx +113 -0
- package/src/ui/Modal/Modal.tsx +97 -0
- package/src/ui/Modal/_modal.scss +24 -0
- package/src/ui/ToolbarButton/ToolbarButton.tsx +26 -0
- package/src/ui/ToolbarButton/_toolbar-button.scss +17 -0
- package/src/ui/ToolbarDropdown/ToolbarDropdown.spec.tsx +78 -0
- package/src/ui/ToolbarDropdown/ToolbarDropdown.tsx +42 -0
- package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +32 -0
- package/src/ui/ToolbarDropdownButton/ToolbarDropdownButton.spec.tsx +48 -0
- package/src/ui/ToolbarDropdownButton/ToolbarDropdownButton.tsx +29 -0
- package/src/ui/ToolbarDropdownButton/_toolbar-dropdown-button.scss +14 -0
- package/src/ui/_buttons.scss +19 -0
- package/src/ui/_forms.scss +16 -0
- package/src/utils/createToolbarPositioner.ts +115 -0
- package/src/utils/getCursorRect.ts +5 -0
- package/tailwind.config.cjs +83 -0
- package/tests/index.ts +2 -0
- package/tests/renderWithEditor.tsx +110 -0
- package/tests/select.tsx +16 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +19 -0
- package/lib/EditorToolbar/EditorToolbar.d.ts +0 -7
- package/lib/EditorToolbar/EditorToolbar.js +0 -22
@@ -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 @@
|
|
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,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(' ');
|
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} </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);
|