@squiz/formatted-text-editor 1.21.1-alpha.7 → 1.22.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/demo/App.tsx +52 -10
- package/demo/index.scss +11 -10
- package/jest.config.ts +0 -2
- package/lib/Editor/Editor.js +45 -7
- package/lib/Editor/EditorContext.d.ts +15 -0
- package/lib/Editor/EditorContext.js +15 -0
- package/lib/EditorToolbar/FloatingToolbar.js +11 -5
- package/lib/EditorToolbar/Tools/Image/Form/ImageForm.d.ts +9 -8
- package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +91 -23
- package/lib/EditorToolbar/Tools/Image/ImageButton.d.ts +4 -1
- package/lib/EditorToolbar/Tools/Image/ImageButton.js +22 -14
- package/lib/EditorToolbar/Tools/Image/ImageModal.js +9 -5
- package/lib/EditorToolbar/Tools/Link/Form/LinkForm.d.ts +14 -5
- package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +66 -14
- package/lib/EditorToolbar/Tools/Link/LinkButton.js +21 -13
- package/lib/EditorToolbar/Tools/Link/LinkModal.js +12 -5
- package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +1 -8
- package/lib/Extensions/CommandsExtension/CommandsExtension.d.ts +20 -0
- package/lib/Extensions/CommandsExtension/CommandsExtension.js +52 -0
- package/lib/Extensions/Extensions.d.ts +12 -5
- package/lib/Extensions/Extensions.js +42 -20
- package/lib/Extensions/ImageExtension/AssetImageExtension.d.ts +17 -0
- package/lib/Extensions/ImageExtension/AssetImageExtension.js +92 -0
- package/lib/Extensions/ImageExtension/ImageExtension.d.ts +4 -0
- package/lib/Extensions/ImageExtension/ImageExtension.js +11 -0
- package/lib/Extensions/LinkExtension/AssetLinkExtension.d.ts +26 -0
- package/lib/Extensions/LinkExtension/AssetLinkExtension.js +102 -0
- package/lib/Extensions/LinkExtension/LinkExtension.d.ts +19 -12
- package/lib/Extensions/LinkExtension/LinkExtension.js +56 -66
- package/lib/Extensions/LinkExtension/common.d.ts +7 -0
- package/lib/Extensions/LinkExtension/common.js +14 -0
- package/lib/Extensions/PreformattedExtension/PreformattedExtension.d.ts +1 -1
- package/lib/Extensions/PreformattedExtension/PreformattedExtension.js +6 -2
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useExpandedSelection.d.ts +23 -0
- package/lib/hooks/useExpandedSelection.js +37 -0
- package/lib/index.css +58 -23
- package/lib/index.d.ts +5 -2
- package/lib/index.js +9 -3
- package/lib/types.d.ts +3 -0
- package/lib/types.js +2 -0
- package/lib/ui/Button/Button.d.ts +2 -1
- package/lib/ui/Button/Button.js +4 -5
- package/lib/ui/Fields/Input/Input.d.ts +1 -0
- package/lib/ui/Fields/Input/Input.js +9 -3
- package/lib/ui/Modal/Modal.js +5 -3
- package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.d.ts +9 -0
- package/lib/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.js +174 -0
- package/lib/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.d.ts +9 -0
- package/lib/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.js +138 -0
- package/lib/utils/resolveMatrixAssetUrl.d.ts +1 -0
- package/lib/utils/resolveMatrixAssetUrl.js +10 -0
- package/lib/utils/undefinedIfEmpty.d.ts +1 -0
- package/lib/utils/undefinedIfEmpty.js +7 -0
- package/package.json +10 -4
- package/src/Editor/Editor.spec.tsx +78 -18
- package/src/Editor/Editor.tsx +28 -9
- package/src/Editor/EditorContext.spec.tsx +26 -0
- package/src/Editor/EditorContext.ts +26 -0
- package/src/Editor/_editor.scss +20 -4
- package/src/EditorToolbar/FloatingToolbar.spec.tsx +26 -7
- package/src/EditorToolbar/FloatingToolbar.tsx +15 -6
- package/src/EditorToolbar/Tools/Image/Form/ImageForm.spec.tsx +81 -6
- package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +167 -47
- package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +250 -2
- package/src/EditorToolbar/Tools/Image/ImageButton.tsx +29 -16
- package/src/EditorToolbar/Tools/Image/ImageModal.spec.tsx +59 -20
- package/src/EditorToolbar/Tools/Image/ImageModal.tsx +12 -10
- package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +37 -9
- package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +96 -26
- package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +137 -26
- package/src/EditorToolbar/Tools/Link/LinkButton.tsx +28 -19
- package/src/EditorToolbar/Tools/Link/LinkModal.tsx +13 -6
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +27 -26
- package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +2 -10
- package/src/EditorToolbar/Tools/Undo/UndoButton.spec.tsx +22 -1
- package/src/EditorToolbar/_floating-toolbar.scss +4 -5
- package/src/EditorToolbar/_toolbar.scss +1 -1
- package/src/Extensions/CommandsExtension/CommandsExtension.ts +54 -0
- package/src/Extensions/Extensions.ts +42 -18
- package/src/Extensions/ImageExtension/AssetImageExtension.spec.ts +76 -0
- package/src/Extensions/ImageExtension/AssetImageExtension.ts +111 -0
- package/src/Extensions/ImageExtension/ImageExtension.ts +17 -1
- package/src/Extensions/LinkExtension/AssetLinkExtension.spec.ts +104 -0
- package/src/Extensions/LinkExtension/AssetLinkExtension.ts +128 -0
- package/src/Extensions/LinkExtension/LinkExtension.spec.ts +68 -0
- package/src/Extensions/LinkExtension/LinkExtension.ts +71 -85
- package/src/Extensions/LinkExtension/common.ts +10 -0
- package/src/Extensions/PreformattedExtension/PreformattedExtension.spec.ts +41 -0
- package/src/Extensions/PreformattedExtension/PreformattedExtension.ts +6 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useExpandedSelection.ts +44 -0
- package/src/index.ts +5 -2
- package/src/types.ts +5 -0
- package/src/ui/Button/Button.tsx +10 -6
- package/src/ui/Button/_button.scss +1 -1
- package/src/ui/Fields/Input/Input.spec.tsx +7 -1
- package/src/ui/Fields/Input/Input.tsx +23 -4
- package/src/ui/Modal/Modal.spec.tsx +15 -0
- package/src/ui/Modal/Modal.tsx +8 -4
- package/src/ui/ToolbarDropdown/_toolbar-dropdown.scss +1 -1
- package/src/ui/_forms.scss +14 -0
- package/src/utils/converters/mocks/squizNodeJson.mock.ts +271 -0
- package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.spec.ts +480 -0
- package/src/utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode.ts +212 -0
- package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.spec.ts +341 -0
- package/src/utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode.ts +159 -0
- package/src/utils/resolveMatrixAssetUrl.spec.ts +26 -0
- package/src/utils/resolveMatrixAssetUrl.ts +7 -0
- package/src/utils/undefinedIfEmpty.spec.ts +12 -0
- package/src/utils/undefinedIfEmpty.ts +3 -0
- package/tailwind.config.cjs +3 -0
- package/tests/renderWithEditor.tsx +28 -15
- package/tsconfig.json +1 -1
- package/lib/FormattedTextEditor.d.ts +0 -2
- package/lib/FormattedTextEditor.js +0 -7
- package/src/Editor/Editor.mock.tsx +0 -43
- package/src/FormattedTextEditor.spec.tsx +0 -10
- package/src/FormattedTextEditor.tsx +0 -3
- /package/tests/{select.tsx → select.ts} +0 -0
@@ -1,26 +1,55 @@
|
|
1
|
-
import {
|
2
|
-
|
3
|
-
|
4
|
-
|
1
|
+
import {
|
2
|
+
ApplySchemaAttributes,
|
3
|
+
ExtensionPriority,
|
4
|
+
FromToProps,
|
5
|
+
isElementDomNode,
|
6
|
+
MarkExtensionSpec,
|
7
|
+
MarkSpecOverride,
|
8
|
+
} from 'remirror';
|
9
|
+
import { command, CommandFunction, extension, MarkExtension, omitExtraAttributes, removeMark } from '@remirror/core';
|
10
|
+
import { LinkTarget, validateTarget } from './common';
|
11
|
+
import { CommandsExtension } from '../CommandsExtension/CommandsExtension';
|
12
|
+
import { MarkName } from '../Extensions';
|
13
|
+
|
14
|
+
export type LinkAttributes = {
|
15
|
+
href: string;
|
16
|
+
title?: string;
|
17
|
+
target: LinkTarget;
|
18
|
+
};
|
5
19
|
|
6
|
-
export type
|
7
|
-
|
20
|
+
export type LinkOptions = {
|
21
|
+
defaultTarget?: LinkTarget;
|
22
|
+
supportedTargets?: LinkTarget[];
|
8
23
|
};
|
9
24
|
|
10
|
-
export type
|
11
|
-
|
25
|
+
export type UpdateLinkProps = {
|
26
|
+
text: string;
|
27
|
+
attrs: Partial<LinkAttributes>;
|
28
|
+
range: FromToProps;
|
12
29
|
};
|
13
30
|
|
14
|
-
|
15
|
-
|
16
|
-
|
31
|
+
@extension<LinkOptions>({
|
32
|
+
defaultOptions: {
|
33
|
+
defaultTarget: LinkTarget.Self,
|
34
|
+
supportedTargets: [LinkTarget.Self, LinkTarget.Blank],
|
35
|
+
},
|
36
|
+
defaultPriority: ExtensionPriority.Medium,
|
37
|
+
})
|
38
|
+
export class LinkExtension extends MarkExtension<LinkOptions> {
|
39
|
+
get name(): string {
|
40
|
+
return MarkName.Link;
|
41
|
+
}
|
17
42
|
|
43
|
+
createMarkSpec(extra: ApplySchemaAttributes, override: MarkSpecOverride): MarkExtensionSpec {
|
18
44
|
return {
|
19
|
-
|
20
|
-
excludes:
|
45
|
+
inclusive: false,
|
46
|
+
excludes: MarkName.AssetLink,
|
47
|
+
...override,
|
21
48
|
attrs: {
|
22
|
-
...
|
49
|
+
...extra.defaults(),
|
50
|
+
href: {},
|
23
51
|
title: { default: undefined },
|
52
|
+
target: { default: this.options.defaultTarget },
|
24
53
|
},
|
25
54
|
parseDOM: [
|
26
55
|
{
|
@@ -32,88 +61,45 @@ export class LinkExtension extends RemirrorLinkExtension {
|
|
32
61
|
|
33
62
|
return {
|
34
63
|
...extra.parse(node),
|
35
|
-
auto: false,
|
36
64
|
href: node.getAttribute('href'),
|
37
|
-
target: node.getAttribute('target'),
|
38
65
|
title: node.getAttribute('title'),
|
66
|
+
target: validateTarget(
|
67
|
+
node.getAttribute('target'),
|
68
|
+
this.options.supportedTargets,
|
69
|
+
this.options.defaultTarget,
|
70
|
+
),
|
39
71
|
};
|
40
72
|
},
|
41
73
|
},
|
42
74
|
],
|
75
|
+
toDOM: (node) => {
|
76
|
+
const { target, ...rest } = omitExtraAttributes(node.attrs, extra);
|
77
|
+
const rel = 'noopener noreferrer nofollow';
|
78
|
+
const attrs = {
|
79
|
+
...extra.dom(node),
|
80
|
+
...rest,
|
81
|
+
rel,
|
82
|
+
target: validateTarget(target, this.options.supportedTargets, this.options.defaultTarget),
|
83
|
+
};
|
84
|
+
|
85
|
+
return ['a', attrs, 0];
|
86
|
+
},
|
43
87
|
};
|
44
88
|
}
|
45
89
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
selectedText,
|
55
|
-
from,
|
56
|
-
to,
|
90
|
+
@command()
|
91
|
+
updateLink({ text, attrs, range }: UpdateLinkProps): CommandFunction {
|
92
|
+
return this.store.getExtension(CommandsExtension).updateMark({
|
93
|
+
attrs,
|
94
|
+
text,
|
95
|
+
range,
|
96
|
+
mark: this.type,
|
97
|
+
removeMark: !attrs.href,
|
57
98
|
});
|
58
|
-
|
59
|
-
return true;
|
60
99
|
}
|
61
100
|
|
62
|
-
|
63
|
-
|
64
|
-
|
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
|
-
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
|
-
tr.addMark(range.from, range.to, this.type.create(attrs));
|
105
|
-
} else {
|
106
|
-
tr.removeMark(range.from, range.to, this.type);
|
107
|
-
}
|
108
|
-
|
109
|
-
// move the cursor to the end of the link and re-focus the editor.
|
110
|
-
tr.setSelection(getTextSelection({ from: range.to, to: range.to }, tr.doc));
|
111
|
-
|
112
|
-
// apply the transaction.
|
113
|
-
dispatch?.(tr);
|
114
|
-
view?.focus();
|
115
|
-
|
116
|
-
return true;
|
117
|
-
};
|
101
|
+
@command()
|
102
|
+
removeLink(): CommandFunction {
|
103
|
+
return removeMark({ type: this.type });
|
118
104
|
}
|
119
105
|
}
|
@@ -0,0 +1,10 @@
|
|
1
|
+
export enum LinkTarget {
|
2
|
+
Self = '_self',
|
3
|
+
Blank = '_blank',
|
4
|
+
Parent = '_parent',
|
5
|
+
Top = '_top',
|
6
|
+
}
|
7
|
+
|
8
|
+
export const validateTarget = (target: unknown, supportedTargets: LinkTarget[], defaultTarget: LinkTarget) => {
|
9
|
+
return supportedTargets.includes(target as LinkTarget) ? (target as LinkTarget) : defaultTarget;
|
10
|
+
};
|
@@ -0,0 +1,41 @@
|
|
1
|
+
import { renderWithEditor } from '../../../tests';
|
2
|
+
|
3
|
+
describe('AssetLinkExtension', () => {
|
4
|
+
it('Parses HTML content with preformatted text', async () => {
|
5
|
+
const { getJsonContent } = await renderWithEditor(null, {
|
6
|
+
content: `<pre>This is some preformatted text</pre>`,
|
7
|
+
});
|
8
|
+
|
9
|
+
expect(getJsonContent()).toEqual({
|
10
|
+
type: 'preformatted',
|
11
|
+
attrs: expect.any(Object),
|
12
|
+
content: [
|
13
|
+
{
|
14
|
+
type: 'text',
|
15
|
+
text: 'This is some preformatted text',
|
16
|
+
},
|
17
|
+
],
|
18
|
+
});
|
19
|
+
});
|
20
|
+
|
21
|
+
it('Outputs expected HTML', async () => {
|
22
|
+
const { getHtmlContent } = await renderWithEditor(null, {
|
23
|
+
content: {
|
24
|
+
type: 'doc',
|
25
|
+
content: [
|
26
|
+
{
|
27
|
+
type: 'preformatted',
|
28
|
+
content: [
|
29
|
+
{
|
30
|
+
type: 'text',
|
31
|
+
text: 'This is some preformatted text',
|
32
|
+
},
|
33
|
+
],
|
34
|
+
},
|
35
|
+
],
|
36
|
+
},
|
37
|
+
});
|
38
|
+
|
39
|
+
expect(getHtmlContent()).toBe('<pre style="">This is some preformatted text</pre>');
|
40
|
+
});
|
41
|
+
});
|
@@ -30,9 +30,13 @@ export class PreformattedExtension extends NodeExtension {
|
|
30
30
|
attrs: {
|
31
31
|
...extra.defaults(),
|
32
32
|
},
|
33
|
-
parseDOM: [
|
33
|
+
parseDOM: [
|
34
|
+
{
|
35
|
+
tag: 'pre',
|
36
|
+
},
|
37
|
+
],
|
34
38
|
toDOM: (node: ProsemirrorNode) => {
|
35
|
-
return [
|
39
|
+
return ['pre', extra.dom(node), 0];
|
36
40
|
},
|
37
41
|
};
|
38
42
|
}
|
package/src/hooks/index.ts
CHANGED
@@ -0,0 +1,44 @@
|
|
1
|
+
import { Selection } from '@remirror/core';
|
2
|
+
import { GetMarkRange, getMarkRanges, getTextSelection, Mark, MarkType } from 'remirror';
|
3
|
+
import { useEditorState } from '@remirror/react';
|
4
|
+
|
5
|
+
export type ExpandedSelection = {
|
6
|
+
selection: Selection;
|
7
|
+
marks: Mark[];
|
8
|
+
};
|
9
|
+
|
10
|
+
/**
|
11
|
+
* Returns a single range that is either:
|
12
|
+
* 1. The current selection if the current selection does not contain any of the indicated marks.
|
13
|
+
* 2. An expanded version of the current selection if the range contains any of the indicated marks.
|
14
|
+
*
|
15
|
+
* For example, given the content:
|
16
|
+
*
|
17
|
+
* <strong>Bold</strong> regular <u>underline</u> <u>more underline</u>
|
18
|
+
*
|
19
|
+
* If the marks passed in are 'bold' and 'underline' and the text "old regular under" is selected
|
20
|
+
* the returned range will be "Bold regular underline".
|
21
|
+
*
|
22
|
+
* @param {(MarkType|string)[]} markTypes
|
23
|
+
*
|
24
|
+
* @return {ExpandedSelection}
|
25
|
+
*/
|
26
|
+
export const useExpandedSelection = (markTypes: (MarkType | string)[]): ExpandedSelection => {
|
27
|
+
const { doc, selection } = useEditorState();
|
28
|
+
const ranges: GetMarkRange[] = [];
|
29
|
+
|
30
|
+
markTypes.forEach((markType) => {
|
31
|
+
ranges.push(...getMarkRanges(selection, markType));
|
32
|
+
});
|
33
|
+
|
34
|
+
if (ranges.length === 0) {
|
35
|
+
return { selection, marks: [] };
|
36
|
+
}
|
37
|
+
|
38
|
+
// work out the start position of the first link and end position of the last link in the selection.
|
39
|
+
const from = Math.min(selection.from, ...ranges.map((range) => range.from));
|
40
|
+
const to = Math.max(selection.to, ...ranges.map((range) => range.to));
|
41
|
+
const marks = ranges.map((range) => range.mark);
|
42
|
+
|
43
|
+
return { selection: getTextSelection({ from, to }, doc), marks };
|
44
|
+
};
|
package/src/index.ts
CHANGED
@@ -1,3 +1,6 @@
|
|
1
|
-
import
|
1
|
+
import Editor from './Editor/Editor';
|
2
|
+
import { EditorContext } from './Editor/EditorContext';
|
3
|
+
import { remirrorNodeToSquizNode } from './utils/converters/remirrorNodeToSquizNode/remirrorNodeToSquizNode';
|
4
|
+
import { squizNodeToRemirrorNode } from './utils/converters/squizNodeToRemirrorNode/squizNodeToRemirrorNode';
|
2
5
|
|
3
|
-
export {
|
6
|
+
export { Editor, EditorContext, remirrorNodeToSquizNode, squizNodeToRemirrorNode };
|
package/src/types.ts
ADDED
package/src/ui/Button/Button.tsx
CHANGED
@@ -8,9 +8,10 @@ type ButtonProps = {
|
|
8
8
|
label: string;
|
9
9
|
text?: string;
|
10
10
|
icon?: ReactElement;
|
11
|
+
className?: string;
|
11
12
|
};
|
12
13
|
|
13
|
-
const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon }: ButtonProps) => {
|
14
|
+
const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon, className }: ButtonProps) => {
|
14
15
|
return (
|
15
16
|
<button
|
16
17
|
aria-label={label}
|
@@ -18,12 +19,15 @@ const Button = ({ handleOnClick, isDisabled, isActive, label, text, icon }: Butt
|
|
18
19
|
type="button"
|
19
20
|
onClick={handleOnClick}
|
20
21
|
disabled={isDisabled}
|
21
|
-
className={clsx(
|
22
|
+
className={clsx(
|
23
|
+
'squiz-fte-btn',
|
24
|
+
isActive && 'squiz-fte-btn--is-active',
|
25
|
+
icon && ' squiz-fte-btn--is-icon',
|
26
|
+
className,
|
27
|
+
)}
|
22
28
|
>
|
23
|
-
|
24
|
-
|
25
|
-
{icon && icon}
|
26
|
-
</>
|
29
|
+
{text && <span>{text}</span>}
|
30
|
+
{icon && icon}
|
27
31
|
</button>
|
28
32
|
);
|
29
33
|
};
|
@@ -7,7 +7,7 @@ describe('Input', () => {
|
|
7
7
|
const mockOnChange = jest.fn();
|
8
8
|
|
9
9
|
const InputComponent = () => {
|
10
|
-
return <Input name="text-input" defaultValue="Water" label="Text input" onChange={mockOnChange} />;
|
10
|
+
return <Input name="text-input" defaultValue="Water" label="Text input" onChange={mockOnChange} required />;
|
11
11
|
};
|
12
12
|
|
13
13
|
it('Renders the label', () => {
|
@@ -40,4 +40,10 @@ describe('Input', () => {
|
|
40
40
|
fireEvent.change(input, { target: { value: 'Wine' } });
|
41
41
|
expect(mockOnChange).toHaveBeenCalled();
|
42
42
|
});
|
43
|
+
|
44
|
+
it('If the field is declared required, it is marked as such', () => {
|
45
|
+
render(<InputComponent />);
|
46
|
+
const requiredMarker = screen.getByLabelText('Required field');
|
47
|
+
expect(requiredMarker).toBeInTheDocument();
|
48
|
+
});
|
43
49
|
});
|
@@ -1,19 +1,38 @@
|
|
1
1
|
import React, { ForwardedRef, forwardRef, InputHTMLAttributes } from 'react';
|
2
|
+
import clsx from 'clsx';
|
2
3
|
|
3
4
|
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
4
5
|
label?: string;
|
6
|
+
error?: string;
|
5
7
|
};
|
6
8
|
|
7
|
-
const InputInternal = (
|
9
|
+
const InputInternal = (
|
10
|
+
{ name, label, type = 'text', error, required, ...rest }: InputProps,
|
11
|
+
ref: ForwardedRef<HTMLInputElement>,
|
12
|
+
) => {
|
8
13
|
return (
|
9
|
-
|
14
|
+
<div className={clsx(error && 'squiz-fte-invalid-form-field')}>
|
10
15
|
{label && (
|
11
16
|
<label htmlFor={name} className="squiz-fte-form-label">
|
12
17
|
{label}
|
13
18
|
</label>
|
14
19
|
)}
|
15
|
-
|
16
|
-
|
20
|
+
{required && (
|
21
|
+
<span className="text-gray-600" aria-label="Required field">
|
22
|
+
*
|
23
|
+
</span>
|
24
|
+
)}
|
25
|
+
<input
|
26
|
+
ref={ref}
|
27
|
+
id={name}
|
28
|
+
name={name}
|
29
|
+
type={type}
|
30
|
+
aria-invalid={!!error}
|
31
|
+
className="squiz-fte-form-control"
|
32
|
+
{...rest}
|
33
|
+
/>
|
34
|
+
{error && <div className="squiz-fte-form-error">{error}</div>}
|
35
|
+
</div>
|
17
36
|
);
|
18
37
|
};
|
19
38
|
|
@@ -2,6 +2,8 @@ import '@testing-library/jest-dom';
|
|
2
2
|
import { render, screen, fireEvent } from '@testing-library/react';
|
3
3
|
import React from 'react';
|
4
4
|
import Modal from './Modal';
|
5
|
+
import { Select } from '../Fields/Select/Select';
|
6
|
+
import { Input } from '../Fields/Input/Input';
|
5
7
|
|
6
8
|
describe('Modal', () => {
|
7
9
|
const mockOnCancel = jest.fn();
|
@@ -110,4 +112,17 @@ describe('Modal', () => {
|
|
110
112
|
|
111
113
|
expect(screen.getByLabelText('My input')).toHaveFocus();
|
112
114
|
});
|
115
|
+
|
116
|
+
it('Auto-focuses on the first select field on mount', () => {
|
117
|
+
render(
|
118
|
+
<Modal title="Modal title" onCancel={mockOnCancel} onSubmit={mockOnSubmit}>
|
119
|
+
<>
|
120
|
+
<Select label="Dropdown" name="select" options={{}} />
|
121
|
+
<Input label="Input" name="input" />
|
122
|
+
</>
|
123
|
+
</Modal>,
|
124
|
+
);
|
125
|
+
|
126
|
+
expect(screen.getByLabelText('Dropdown')).toHaveFocus();
|
127
|
+
});
|
113
128
|
});
|
package/src/ui/Modal/Modal.tsx
CHANGED
@@ -1,7 +1,8 @@
|
|
1
|
-
import React, { ForwardedRef, forwardRef, ReactElement, useEffect, useMemo } from 'react';
|
1
|
+
import React, { ForwardedRef, forwardRef, ReactElement, useEffect, useMemo, useRef } from 'react';
|
2
2
|
import { createPortal } from 'react-dom';
|
3
3
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
4
4
|
import { FocusTrap } from '@mui/base';
|
5
|
+
import clsx from 'clsx';
|
5
6
|
|
6
7
|
export type ModalProps = {
|
7
8
|
title: string;
|
@@ -15,6 +16,7 @@ const Modal = (
|
|
15
16
|
{ children, title, onCancel, onSubmit, className }: ModalProps,
|
16
17
|
ref: ForwardedRef<HTMLDivElement>,
|
17
18
|
): ReactElement => {
|
19
|
+
const content = useRef<HTMLDivElement>(null);
|
18
20
|
const container = useMemo(() => {
|
19
21
|
const element = document.createElement('div');
|
20
22
|
element.classList.add('squiz-fte-scope');
|
@@ -37,7 +39,7 @@ const Modal = (
|
|
37
39
|
|
38
40
|
// add/remove the modal container from the DOM and focus on the first input
|
39
41
|
useEffect(() => {
|
40
|
-
const firstInput =
|
42
|
+
const firstInput = content.current?.querySelector('input:not([type=hidden]), button') as HTMLElement;
|
41
43
|
|
42
44
|
document.body.appendChild(container);
|
43
45
|
firstInput?.focus();
|
@@ -50,7 +52,7 @@ const Modal = (
|
|
50
52
|
return createPortal(
|
51
53
|
<>
|
52
54
|
<FocusTrap open>
|
53
|
-
<div ref={ref} className={
|
55
|
+
<div ref={ref} className={clsx('squiz-fte-modal-wrapper', className)} tabIndex={-1}>
|
54
56
|
<div className="w-modal-sm my-6 mx-auto">
|
55
57
|
<div className="squiz-fte-modal">
|
56
58
|
<div className="squiz-fte-modal-header p-6 pb-2">
|
@@ -64,7 +66,9 @@ const Modal = (
|
|
64
66
|
<CloseRoundedIcon />
|
65
67
|
</button>
|
66
68
|
</div>
|
67
|
-
<div className="squiz-fte-modal-content"
|
69
|
+
<div className="squiz-fte-modal-content" ref={content}>
|
70
|
+
{children}
|
71
|
+
</div>
|
68
72
|
<div className="squiz-fte-modal-footer p-6 pt-3">
|
69
73
|
<button
|
70
74
|
className="squiz-fte-modal-footer__button bg-gray-200 text-gray-700 mr-2 hover:bg-gray-300"
|
package/src/ui/_forms.scss
CHANGED
@@ -13,4 +13,18 @@
|
|
13
13
|
box-shadow: none;
|
14
14
|
}
|
15
15
|
}
|
16
|
+
&-invalid-form-field {
|
17
|
+
.squiz-fte-form-control {
|
18
|
+
@apply border-red-300 bg-no-repeat pr-8;
|
19
|
+
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHdpZHRoPSIyNCI+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0ibm9uZSIvPjxnIGNsYXNzPSJjdXJyZW50TGF5ZXIiPjxwYXRoIGQ9Ik00LjQ3IDIxaDE1LjA2YzEuNTQgMCAyLjUtMS42NyAxLjczLTNMMTMuNzMgNC45OWMtLjc3LTEuMzMtMi42OS0xLjMzLTMuNDYgMEwyLjc0IDE4Yy0uNzcgMS4zMy4xOSAzIDEuNzMgM3pNMTIgMTRjLS41NSAwLTEtLjQ1LTEtMXYtMmMwLS41NS40NS0xIDEtMXMxIC40NSAxIDF2MmMwIC41NS0uNDUgMS0xIDF6bTEgNGgtMnYtMmgydjJ6IiBjbGFzcz0ic2VsZWN0ZWQiIGZpbGw9IiNkNzIzMjEiLz48L2c+PC9zdmc+');
|
20
|
+
background-position: top 0.25rem right 0.25rem;
|
21
|
+
background-size: 1.5rem;
|
22
|
+
}
|
23
|
+
}
|
24
|
+
&-form-error {
|
25
|
+
@apply text-red-300;
|
26
|
+
font-size: 13px;
|
27
|
+
line-height: 1.23;
|
28
|
+
padding-top: 0.25rem;
|
29
|
+
}
|
16
30
|
}
|