@squiz/formatted-text-editor 1.33.1-alpha.2 → 1.33.1-alpha.4

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 (79) hide show
  1. package/demo/App.tsx +4 -24
  2. package/demo/AppContext.tsx +28 -0
  3. package/demo/index.scss +0 -2
  4. package/demo/main.tsx +2 -0
  5. package/demo/resources.json +28 -0
  6. package/demo/sources.json +23 -0
  7. package/lib/Editor/EditorContext.d.ts +0 -7
  8. package/lib/Editor/EditorContext.js +0 -2
  9. package/lib/EditorToolbar/Tools/Image/Form/ImageForm.js +16 -32
  10. package/lib/EditorToolbar/Tools/Image/ImageModal.js +3 -2
  11. package/lib/EditorToolbar/Tools/Link/Form/LinkForm.js +18 -58
  12. package/lib/EditorToolbar/Tools/Link/LinkModal.js +3 -2
  13. package/lib/EditorToolbar/Tools/Link/RemoveLinkButton.js +1 -1
  14. package/lib/Extensions/Extensions.js +0 -2
  15. package/lib/Extensions/ImageExtension/AssetImageExtension.d.ts +0 -1
  16. package/lib/Extensions/ImageExtension/AssetImageExtension.js +1 -2
  17. package/lib/Extensions/LinkExtension/AssetLinkExtension.d.ts +0 -1
  18. package/lib/Extensions/LinkExtension/AssetLinkExtension.js +2 -3
  19. package/lib/Extensions/LinkExtension/LinkExtension.js +1 -1
  20. package/lib/index.css +84 -4
  21. package/lib/types.d.ts +3 -3
  22. package/lib/ui/Fields/Checkbox/Checkbox.d.ts +8 -0
  23. package/lib/ui/Fields/Checkbox/Checkbox.js +47 -0
  24. package/lib/ui/Fields/Input/Input.d.ts +2 -4
  25. package/lib/ui/Fields/Input/Input.js +3 -9
  26. package/lib/ui/Fields/InputContainer/InputContainer.d.ts +9 -0
  27. package/lib/ui/Fields/InputContainer/InputContainer.js +16 -0
  28. package/lib/ui/Fields/MatrixAsset/MatrixAsset.d.ts +17 -0
  29. package/lib/ui/Fields/MatrixAsset/MatrixAsset.js +29 -0
  30. package/lib/ui/Modal/Modal.d.ts +1 -0
  31. package/lib/ui/Modal/Modal.js +3 -2
  32. package/lib/ui/Tabs/Tabs.d.ts +10 -0
  33. package/lib/ui/Tabs/Tabs.js +46 -0
  34. package/lib/utils/validation.d.ts +2 -1
  35. package/lib/utils/validation.js +8 -2
  36. package/package.json +4 -3
  37. package/src/Editor/Editor.spec.tsx +1 -1
  38. package/src/Editor/EditorContext.spec.tsx +11 -13
  39. package/src/Editor/EditorContext.ts +0 -11
  40. package/src/EditorToolbar/Tools/Image/Form/ImageForm.spec.tsx +29 -12
  41. package/src/EditorToolbar/Tools/Image/Form/ImageForm.tsx +37 -53
  42. package/src/EditorToolbar/Tools/Image/ImageButton.spec.tsx +76 -49
  43. package/src/EditorToolbar/Tools/Image/ImageModal.spec.tsx +1 -0
  44. package/src/EditorToolbar/Tools/Image/ImageModal.tsx +3 -2
  45. package/src/EditorToolbar/Tools/Link/Form/LinkForm.spec.tsx +22 -13
  46. package/src/EditorToolbar/Tools/Link/Form/LinkForm.tsx +35 -57
  47. package/src/EditorToolbar/Tools/Link/LinkButton.spec.tsx +52 -36
  48. package/src/EditorToolbar/Tools/Link/LinkModal.tsx +3 -2
  49. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.spec.tsx +47 -4
  50. package/src/EditorToolbar/Tools/Link/RemoveLinkButton.tsx +3 -2
  51. package/src/Extensions/Extensions.ts +0 -2
  52. package/src/Extensions/ImageExtension/AssetImageExtension.ts +1 -3
  53. package/src/Extensions/LinkExtension/AssetLinkExtension.ts +2 -4
  54. package/src/Extensions/LinkExtension/LinkExtension.ts +1 -1
  55. package/src/index.scss +1 -0
  56. package/src/types.ts +7 -5
  57. package/src/ui/Fields/Checkbox/Checkbox.spec.tsx +50 -0
  58. package/src/ui/Fields/Checkbox/Checkbox.tsx +49 -0
  59. package/src/ui/Fields/Checkbox/_checkbox.scss +26 -0
  60. package/src/ui/Fields/Input/Input.tsx +4 -18
  61. package/src/ui/Fields/InputContainer/InputContainer.spec.tsx +18 -0
  62. package/src/ui/Fields/InputContainer/InputContainer.tsx +29 -0
  63. package/src/ui/Fields/MatrixAsset/MatrixAsset.spec.tsx +103 -0
  64. package/src/ui/Fields/MatrixAsset/MatrixAsset.tsx +55 -0
  65. package/src/ui/Modal/FormModal.spec.tsx +2 -1
  66. package/src/ui/Modal/Modal.spec.tsx +15 -7
  67. package/src/ui/Modal/Modal.tsx +4 -2
  68. package/src/ui/Tabs/Tabs.spec.tsx +44 -0
  69. package/src/ui/Tabs/Tabs.tsx +41 -0
  70. package/src/ui/_forms.scss +4 -2
  71. package/src/utils/validation.spec.ts +22 -0
  72. package/src/utils/validation.ts +9 -1
  73. package/tests/index.ts +2 -0
  74. package/tests/mockResourceBrowserContext.tsx +63 -0
  75. package/tests/renderWithContext.tsx +18 -0
  76. package/tests/renderWithEditor.tsx +18 -21
  77. package/vite.config.ts +8 -0
  78. package/lib/ui/Fields/Select/Select.d.ts +0 -12
  79. package/lib/ui/Fields/Select/Select.js +0 -53
@@ -1,13 +1,14 @@
1
1
  import '@testing-library/jest-dom';
2
2
  import { act, screen, fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
3
3
  import React from 'react';
4
- import { renderWithEditor, select } from '../../../../tests';
4
+ import { renderWithEditor, mockResourceBrowserContext } from '../../../../tests';
5
5
  import LinkButton from './LinkButton';
6
6
 
7
7
  describe('LinkButton', () => {
8
8
  const openModal = async () => {
9
9
  fireEvent.click(screen.getByRole('button', { name: 'Link (cmd+K)' }));
10
10
  await screen.findByRole('button', { name: 'Apply' });
11
+ fireEvent.click(screen.getByRole('button', { name: 'From URL' }));
11
12
  };
12
13
 
13
14
  it('Adds a new link', async () => {
@@ -21,7 +22,7 @@ describe('LinkButton', () => {
21
22
  fireEvent.change(screen.getByLabelText('URL'), { target: { value: 'https://www.squiz.net/link-button' } });
22
23
  fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Link text' } });
23
24
  fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Link title' } });
24
- select(screen.getByLabelText('Target'), 'New window');
25
+ fireEvent.click(document.querySelector('div.squiz-fte-checkbox button') as HTMLButtonElement);
25
26
  fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
26
27
 
27
28
  await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
@@ -235,19 +236,29 @@ describe('LinkButton', () => {
235
236
  it('Add a new asset link', async () => {
236
237
  const matrixIdentifier = 'matrix-api-identifier';
237
238
  const matrixDomain = 'https://my-matrix.squiz.net';
238
- const { getJsonContent } = await renderWithEditor(<LinkButton />, {
239
- context: {
240
- matrix: {
241
- matrixIdentifier,
242
- matrixDomain,
243
- resolveMatrixAsset: () => Promise.resolve({ id: '100', type: 'physical_file' }),
239
+ const { MockResourceBrowserContext, selectResource } = mockResourceBrowserContext({
240
+ sources: [{ id: matrixIdentifier }],
241
+ resources: [{ id: 'my-resource-id', name: 'My resource' }],
242
+ });
243
+
244
+ const { getJsonContent } = await renderWithEditor(
245
+ <MockResourceBrowserContext>
246
+ <LinkButton />
247
+ </MockResourceBrowserContext>,
248
+ {
249
+ context: {
250
+ editor: {
251
+ matrix: {
252
+ matrixDomain,
253
+ },
254
+ },
244
255
  },
245
256
  },
246
- });
257
+ );
247
258
 
248
259
  await openModal();
249
- select(screen.getByLabelText('Type'), 'Link to asset');
250
- fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: '123' } });
260
+ fireEvent.click(screen.getByRole('button', { name: 'From source' }));
261
+ await selectResource(screen.getByRole('button', { name: 'Choose asset' }), 'My resource');
251
262
  fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Link text' } });
252
263
  fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
253
264
 
@@ -263,7 +274,7 @@ describe('LinkButton', () => {
263
274
  marks: [
264
275
  {
265
276
  type: 'assetLink',
266
- attrs: { matrixAssetId: '123', target: '_self', matrixDomain, matrixIdentifier },
277
+ attrs: { matrixAssetId: 'my-resource-id', target: '_self', matrixDomain, matrixIdentifier },
267
278
  },
268
279
  ],
269
280
  },
@@ -274,25 +285,35 @@ describe('LinkButton', () => {
274
285
  it('Updates an existing link to be an asset link', async () => {
275
286
  const matrixIdentifier = 'matrix-api-identifier';
276
287
  const matrixDomain = 'https://my-matrix.squiz.net';
277
- const { editor, getJsonContent } = await renderWithEditor(<LinkButton />, {
278
- content:
279
- '<a href="https://www.example.org/my-link">Sample link</a> with ' +
280
- '<a href="https://www.example.org/another-link">another link</a>',
281
- context: {
282
- matrix: {
283
- matrixIdentifier,
284
- matrixDomain,
285
- resolveMatrixAsset: () => Promise.resolve({ id: '100', type: 'physical_file' }),
288
+ const { MockResourceBrowserContext, selectResource } = mockResourceBrowserContext({
289
+ sources: [{ id: matrixIdentifier }],
290
+ resources: [{ id: 'my-resource-id', name: 'My resource' }],
291
+ });
292
+
293
+ const { editor, getJsonContent } = await renderWithEditor(
294
+ <MockResourceBrowserContext>
295
+ <LinkButton />
296
+ </MockResourceBrowserContext>,
297
+ {
298
+ content:
299
+ '<a href="https://www.example.org/my-link">Sample link</a> with ' +
300
+ '<a href="https://www.example.org/another-link">another link</a>',
301
+ context: {
302
+ editor: {
303
+ matrix: {
304
+ matrixDomain,
305
+ },
306
+ },
286
307
  },
287
308
  },
288
- });
309
+ );
289
310
 
290
311
  await act(() => editor.selectText(5));
291
312
 
292
313
  await openModal();
293
- select(screen.getByLabelText('Type'), 'Link to asset');
294
- fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: '123' } });
295
- select(screen.getByLabelText('Target'), 'New window');
314
+ fireEvent.click(screen.getByRole('button', { name: 'From source' }));
315
+ await selectResource(screen.getByRole('button', { name: 'Choose asset' }), 'My resource');
316
+ fireEvent.click(document.querySelector('div.squiz-fte-checkbox button') as HTMLButtonElement);
296
317
  fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
297
318
 
298
319
  await waitForElementToBeRemoved(() => screen.getByRole('button', { name: 'Apply' }));
@@ -307,7 +328,7 @@ describe('LinkButton', () => {
307
328
  marks: [
308
329
  {
309
330
  type: 'assetLink',
310
- attrs: { matrixAssetId: '123', target: '_blank', matrixDomain, matrixIdentifier },
331
+ attrs: { matrixAssetId: 'my-resource-id', target: '_blank', matrixDomain, matrixIdentifier },
311
332
  },
312
333
  ],
313
334
  },
@@ -326,18 +347,14 @@ describe('LinkButton', () => {
326
347
  });
327
348
  });
328
349
 
329
- it('Shows an error if an invalid asset ID is provided', async () => {
330
- const resolveMatrixAsset = jest.fn(() => Promise.resolve(null));
331
-
332
- await renderWithEditor(<LinkButton />, { context: { matrix: { resolveMatrixAsset } } });
350
+ it('Shows an error if no asset is selected', async () => {
351
+ await renderWithEditor(<LinkButton />);
333
352
 
334
353
  await openModal();
335
- select(screen.getByLabelText('Type'), 'Link to asset');
336
- fireEvent.change(screen.getByLabelText('Asset ID'), { target: { value: 'invalid-asset-id' } });
354
+ fireEvent.click(screen.getByRole('button', { name: 'From source' }));
337
355
  await act(() => fireEvent.click(screen.getByRole('button', { name: 'Apply' })));
338
356
 
339
- expect(screen.getByText('Invalid asset ID')).toBeInTheDocument();
340
- expect(resolveMatrixAsset).toHaveBeenCalledWith('invalid-asset-id');
357
+ expect(screen.getByText('An asset must be selected')).toBeInTheDocument();
341
358
  });
342
359
 
343
360
  it('Shows an error if a required field is not provided', async () => {
@@ -351,7 +368,6 @@ describe('LinkButton', () => {
351
368
 
352
369
  expect(screen.getByText('URL is required')).toBeInTheDocument();
353
370
  expect(screen.getByText('Text is required')).toBeInTheDocument();
354
- expect(screen.getByText('Title is required')).toBeInTheDocument();
355
371
  });
356
372
 
357
373
  it('Shows an error if the field value is just an empty space', async () => {
@@ -363,6 +379,6 @@ describe('LinkButton', () => {
363
379
  fireEvent.change(screen.getByLabelText('Title'), { target: { value: ' ' } });
364
380
  await act(() => fireEvent.click(screen.getByRole('button', { name: 'Apply' })));
365
381
 
366
- expect(screen.getAllByText('Empty space is not allowed')).toHaveLength(3);
382
+ expect(screen.getAllByText('Empty space is not allowed')).toHaveLength(2);
367
383
  });
368
384
  });
@@ -1,6 +1,7 @@
1
1
  import { LinkForm, LinkFormData } from './Form/LinkForm';
2
2
  import React from 'react';
3
3
  import { useRemirrorContext } from '@remirror/react';
4
+ import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded';
4
5
  import FormModal from '../../../ui/Modal/FormModal';
5
6
  import { SubmitHandler } from 'react-hook-form';
6
7
  import { useExpandedSelection } from '../../../hooks';
@@ -19,7 +20,7 @@ const LinkModal = ({ onCancel, onSubmit }: LinkModalProps) => {
19
20
  const { selection, marks } = useExpandedSelection([MarkName.Link, MarkName.AssetLink]);
20
21
  const selectedText = helpers.getTextBetween(selection.from, selection.to, state.doc);
21
22
  const data = {
22
- linkType: marks[0]?.type?.name === MarkName.AssetLink ? MarkName.AssetLink : MarkName.Link,
23
+ linkType: marks[0]?.type?.name === MarkName.Link ? MarkName.Link : MarkName.AssetLink,
23
24
  text: selectedText,
24
25
  link: { ...marks.find((mark) => mark.type.name === 'link')?.attrs },
25
26
  assetLink: { ...marks.find((mark) => mark.type.name === MarkName.AssetLink)?.attrs },
@@ -27,7 +28,7 @@ const LinkModal = ({ onCancel, onSubmit }: LinkModalProps) => {
27
28
  };
28
29
 
29
30
  return (
30
- <FormModal title="Link" onCancel={onCancel}>
31
+ <FormModal title="Link" icon={<InsertLinkRoundedIcon />} onCancel={onCancel}>
31
32
  <LinkForm data={data} onSubmit={onSubmit} />
32
33
  </FormModal>
33
34
  );
@@ -7,7 +7,7 @@ import RemoveLinkButton from './RemoveLinkButton';
7
7
  describe('RemoveLinkButton', () => {
8
8
  it('Removes a link', async () => {
9
9
  const { editor, getJsonContent } = await renderWithEditor(<RemoveLinkButton />, {
10
- context: { matrix: { matrixDomain: 'my-matrix.squiz.net' } },
10
+ context: { editor: { matrix: { matrixDomain: 'my-matrix.squiz.net' } } },
11
11
  content: {
12
12
  type: 'doc',
13
13
  content: [
@@ -17,7 +17,12 @@ describe('RemoveLinkButton', () => {
17
17
  {
18
18
  type: 'text',
19
19
  text: 'Sample link',
20
- marks: [{ type: 'assetLink', attrs: { matrixAssetId: '123', target: '_blank' } }],
20
+ marks: [
21
+ {
22
+ type: 'assetLink',
23
+ attrs: { matrixAssetId: '123', matrixIdentifier: 'matrix-identifier', target: '_blank' },
24
+ },
25
+ ],
21
26
  },
22
27
  { type: 'text', text: ' with ' },
23
28
  {
@@ -47,7 +52,7 @@ describe('RemoveLinkButton', () => {
47
52
 
48
53
  it('Removes the link when clicking the keyboard shortcut', async () => {
49
54
  const { elements, editor, getJsonContent } = await renderWithEditor(<RemoveLinkButton />, {
50
- context: { matrix: { matrixDomain: 'my-matrix.squiz.net' } },
55
+ context: { editor: { matrix: { matrixDomain: 'my-matrix.squiz.net' } } },
51
56
  content: {
52
57
  type: 'doc',
53
58
  content: [
@@ -57,7 +62,12 @@ describe('RemoveLinkButton', () => {
57
62
  {
58
63
  type: 'text',
59
64
  text: 'Sample link',
60
- marks: [{ type: 'assetLink', attrs: { matrixAssetId: '123', target: '_blank' } }],
65
+ marks: [
66
+ {
67
+ type: 'assetLink',
68
+ attrs: { matrixAssetId: '123', matrixIdentifier: 'matrix-identifier', target: '_blank' },
69
+ },
70
+ ],
61
71
  },
62
72
  { type: 'text', text: ' with ' },
63
73
  {
@@ -97,4 +107,37 @@ describe('RemoveLinkButton', () => {
97
107
  // expect remove button to be enabled
98
108
  expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).not.toBeDisabled();
99
109
  });
110
+
111
+ it('Enables the Remove link button when asset link text is selected', async () => {
112
+ const { editor } = await renderWithEditor(<RemoveLinkButton />, {
113
+ context: { editor: { matrix: { matrixDomain: 'my-matrix.squiz.net' } } },
114
+ content: {
115
+ type: 'doc',
116
+ content: [
117
+ {
118
+ type: 'paragraph',
119
+ content: [
120
+ {
121
+ type: 'text',
122
+ text: 'Sample link',
123
+ marks: [
124
+ {
125
+ type: 'assetLink',
126
+ attrs: { matrixAssetId: '123', matrixIdentifier: 'matrix-identifier', target: '_blank' },
127
+ },
128
+ ],
129
+ },
130
+ ],
131
+ },
132
+ ],
133
+ },
134
+ });
135
+
136
+ // expect remove button to be disabled
137
+ expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).toBeDisabled();
138
+ // jump to the middle of the link.
139
+ await act(() => editor.selectText(3));
140
+ // expect remove button to be enabled
141
+ expect(screen.getByRole('button', { name: 'Remove link (shift+cmd+K)' })).not.toBeDisabled();
142
+ });
100
143
  });
@@ -2,13 +2,14 @@ import React, { useCallback } from 'react';
2
2
  import { useChainedCommands, useActive, useKeymap } from '@remirror/react';
3
3
  import Button from '../../../ui/Button/Button';
4
4
  import LinkOffIcon from '@mui/icons-material/LinkOff';
5
+ import { AssetLinkExtension } from '../../../Extensions/LinkExtension/AssetLinkExtension';
5
6
  import { LinkExtension } from '../../../Extensions/LinkExtension/LinkExtension';
6
7
  import { LinkButtonProps } from './LinkButton';
7
8
 
8
9
  const RemoveLinkButton = ({ inPopover = false }: LinkButtonProps) => {
9
10
  const chain = useChainedCommands();
10
- const active = useActive<LinkExtension>();
11
- const disabled = !active.link();
11
+ const active = useActive<LinkExtension | AssetLinkExtension>();
12
+ const disabled = !active.link() && !active.assetLink();
12
13
 
13
14
  const handleClick = () => {
14
15
  chain.removeLink().removeAssetLink().focus().run();
@@ -42,12 +42,10 @@ export const createExtensions = (context: EditorContextOptions) => {
42
42
  new ImageExtension(),
43
43
  new ImageExtension({ preferPastedTextContent: false }),
44
44
  new AssetImageExtension({
45
- matrixIdentifier: context.matrix.matrixIdentifier,
46
45
  matrixDomain: context.matrix.matrixDomain,
47
46
  }),
48
47
  new LinkExtension(),
49
48
  new AssetLinkExtension({
50
- matrixIdentifier: context.matrix.matrixIdentifier,
51
49
  matrixDomain: context.matrix.matrixDomain,
52
50
  }),
53
51
  ];
@@ -16,7 +16,6 @@ import { resolveMatrixAssetUrl } from '../../utils/resolveMatrixAssetUrl';
16
16
  import { NodeName } from '../Extensions';
17
17
 
18
18
  export type AssetImageOptions = {
19
- matrixIdentifier?: string;
20
19
  matrixDomain?: string;
21
20
  };
22
21
 
@@ -28,7 +27,6 @@ export type AssetImageAttributes = {
28
27
 
29
28
  @extension<AssetImageOptions>({
30
29
  defaultOptions: {
31
- matrixIdentifier: '',
32
30
  matrixDomain: '',
33
31
  },
34
32
  defaultPriority: ExtensionPriority.High,
@@ -51,7 +49,7 @@ export class AssetImageExtension extends NodeExtension<AssetImageOptions> {
51
49
  attrs: {
52
50
  ...extra.defaults(),
53
51
  matrixAssetId: {},
54
- matrixIdentifier: { default: this.options.matrixIdentifier },
52
+ matrixIdentifier: {},
55
53
  matrixDomain: { default: this.options.matrixDomain },
56
54
  },
57
55
  parseDOM: [
@@ -20,7 +20,6 @@ export type AssetLinkAttributes = {
20
20
  };
21
21
 
22
22
  export type AssetLinkOptions = {
23
- matrixIdentifier?: string;
24
23
  matrixDomain?: string;
25
24
  defaultTarget?: LinkTarget;
26
25
  supportedTargets?: LinkTarget[];
@@ -34,7 +33,6 @@ export type UpdateAssetLinkProps = {
34
33
 
35
34
  @extension<AssetLinkOptions>({
36
35
  defaultOptions: {
37
- matrixIdentifier: '',
38
36
  matrixDomain: '',
39
37
  defaultTarget: LinkTarget.Self,
40
38
  supportedTargets: [LinkTarget.Self, LinkTarget.Blank],
@@ -49,12 +47,12 @@ export class AssetLinkExtension extends MarkExtension<AssetLinkOptions> {
49
47
  createMarkSpec(extra: ApplySchemaAttributes, override: MarkSpecOverride): MarkExtensionSpec {
50
48
  return {
51
49
  inclusive: false,
52
- excludes: MarkName.Link,
50
+ excludes: [this.name, MarkName.Link].join(' '),
53
51
  ...override,
54
52
  attrs: {
55
53
  ...extra.defaults(),
56
54
  matrixAssetId: {},
57
- matrixIdentifier: { default: this.options.matrixIdentifier },
55
+ matrixIdentifier: {},
58
56
  matrixDomain: { default: this.options.matrixDomain },
59
57
  target: { default: this.options.defaultTarget },
60
58
  },
@@ -43,7 +43,7 @@ export class LinkExtension extends MarkExtension<LinkOptions> {
43
43
  createMarkSpec(extra: ApplySchemaAttributes, override: MarkSpecOverride): MarkExtensionSpec {
44
44
  return {
45
45
  inclusive: false,
46
- excludes: MarkName.AssetLink,
46
+ excludes: [this.name, MarkName.AssetLink].join(' '),
47
47
  ...override,
48
48
  attrs: {
49
49
  ...extra.defaults(),
package/src/index.scss CHANGED
@@ -15,5 +15,6 @@
15
15
  @import './ui/Button/button';
16
16
  @import './ui/ToolbarDropdown/toolbar-dropdown';
17
17
  @import './ui/ToolbarDropdownButton/toolbar-dropdown-button';
18
+ @import './ui/Fields/Checkbox/checkbox';
18
19
 
19
20
  @import './ui/Modal/modal';
package/src/types.ts CHANGED
@@ -1,5 +1,7 @@
1
- export type DeepPartial<T> = T extends Record<string, unknown>
2
- ? {
3
- [P in keyof T]?: DeepPartial<T[P]>;
4
- }
5
- : T;
1
+ export type DeepPartial<T> = {
2
+ [P in keyof T]?: T[P] extends Array<infer U>
3
+ ? Array<DeepPartial<U>>
4
+ : T[P] extends ReadonlyArray<infer U>
5
+ ? ReadonlyArray<DeepPartial<U>>
6
+ : DeepPartial<T[P]>;
7
+ };
@@ -0,0 +1,50 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { Checkbox } from './Checkbox';
5
+ import { LinkTarget } from '../../../Extensions/LinkExtension/common';
6
+
7
+ describe('Input', () => {
8
+ const mockOnChange = jest.fn();
9
+
10
+ const CheckboxComponent = ({ defaultChecked = false }: { defaultChecked?: boolean }) => {
11
+ return (
12
+ <Checkbox
13
+ label="This is a test checkbox"
14
+ onChange={mockOnChange}
15
+ defaultChecked={defaultChecked}
16
+ unchecked={'self' as LinkTarget}
17
+ checked={'_blank' as LinkTarget}
18
+ />
19
+ );
20
+ };
21
+
22
+ it('Renders the checkbox label', () => {
23
+ render(<CheckboxComponent />);
24
+ // Check that the supplied label renders
25
+ const checkboxLabel = screen.getByText('This is a test checkbox');
26
+ expect(checkboxLabel).toBeInTheDocument();
27
+ });
28
+
29
+ it('Renders the default checkmark', () => {
30
+ render(<CheckboxComponent defaultChecked={true} />);
31
+ // Check that default value supplied renders
32
+ expect(screen.getByTestId('CheckRoundedIcon')).toBeInTheDocument();
33
+ });
34
+
35
+ it('Does not render the default checkmark', () => {
36
+ render(<CheckboxComponent defaultChecked={false} />);
37
+ expect(screen.queryByTestId('CheckRoundedIcon')).toBeFalsy();
38
+ });
39
+
40
+ it('Toggles checkbox when it is clicked', () => {
41
+ render(<CheckboxComponent />);
42
+ const checkbox = screen.getAllByRole('button')[0];
43
+
44
+ expect(checkbox).toBeTruthy();
45
+ fireEvent.click(checkbox);
46
+
47
+ expect(mockOnChange).toHaveBeenCalled();
48
+ expect(screen.getByTestId('CheckRoundedIcon')).toBeInTheDocument();
49
+ });
50
+ });
@@ -0,0 +1,49 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
3
+
4
+ export type CheckboxProps<TChecked, TUnchecked> = {
5
+ label: string;
6
+ onChange: (value: TChecked | TUnchecked) => void;
7
+ defaultChecked?: boolean;
8
+ unchecked: TUnchecked;
9
+ checked: TChecked;
10
+ };
11
+
12
+ export const Checkbox = <TChecked, TUnchecked>({
13
+ label,
14
+ onChange,
15
+ defaultChecked = false,
16
+ unchecked,
17
+ checked,
18
+ }: CheckboxProps<TChecked, TUnchecked>) => {
19
+ const [toggled, setToggled] = useState<boolean>(defaultChecked);
20
+
21
+ useEffect(() => {
22
+ if (toggled) {
23
+ onChange(checked);
24
+ } else {
25
+ onChange(unchecked);
26
+ }
27
+ }, [toggled]);
28
+
29
+ const toggleCheckbox = () => setToggled(!toggled);
30
+
31
+ return (
32
+ <div className="squiz-fte-checkbox">
33
+ <button
34
+ type="button"
35
+ role="checkbox"
36
+ aria-label={label}
37
+ aria-checked={toggled}
38
+ className="checkbox"
39
+ onClick={toggleCheckbox}
40
+ >
41
+ {toggled && <CheckRoundedIcon />}
42
+ </button>
43
+ {/* Checkbox label as a button, acts as a secondary way to toggle */}
44
+ <button type="button" className="label" onClick={toggleCheckbox} tabIndex={-1}>
45
+ {label}
46
+ </button>
47
+ </div>
48
+ );
49
+ };
@@ -0,0 +1,26 @@
1
+ .squiz-fte-checkbox {
2
+ @apply text-gray-800;
3
+ font-size: 14px;
4
+
5
+ display: flex;
6
+ align-items: center;
7
+ margin-top: 0.75rem;
8
+ gap: 0.75rem;
9
+
10
+ .checkbox {
11
+ display: flex;
12
+ justify-content: center;
13
+ align-items: center;
14
+
15
+ width: 1.25rem;
16
+ height: 1.25rem;
17
+ background-color: #fff;
18
+
19
+ border: 2px solid #e0e0e0;
20
+ border-radius: 4px;
21
+
22
+ svg {
23
+ width: 100%;
24
+ }
25
+ }
26
+ }
@@ -1,27 +1,14 @@
1
1
  import React, { ForwardedRef, forwardRef, InputHTMLAttributes } from 'react';
2
- import clsx from 'clsx';
2
+ import { InputContainer, InputContainerProps } from '../InputContainer/InputContainer';
3
3
 
4
- type InputProps = InputHTMLAttributes<HTMLInputElement> & {
5
- label?: string;
6
- error?: string;
7
- };
4
+ type InputProps = InputHTMLAttributes<HTMLInputElement> & Omit<InputContainerProps, 'children'>;
8
5
 
9
6
  const InputInternal = (
10
7
  { name, label, type = 'text', error, required, ...rest }: InputProps,
11
8
  ref: ForwardedRef<HTMLInputElement>,
12
9
  ) => {
13
10
  return (
14
- <div className={clsx(error && 'squiz-fte-invalid-form-field')}>
15
- {label && (
16
- <label htmlFor={name} className="squiz-fte-form-label">
17
- {label}
18
- </label>
19
- )}
20
- {required && (
21
- <span className="text-gray-600" aria-label="Required field">
22
- *
23
- </span>
24
- )}
11
+ <InputContainer name={name} label={label} error={error} required={required}>
25
12
  <input
26
13
  ref={ref}
27
14
  id={name}
@@ -31,8 +18,7 @@ const InputInternal = (
31
18
  className="squiz-fte-form-control"
32
19
  {...rest}
33
20
  />
34
- {error && <div className="squiz-fte-form-error">{error}</div>}
35
- </div>
21
+ </InputContainer>
36
22
  );
37
23
  };
38
24
 
@@ -0,0 +1,18 @@
1
+ import '@testing-library/jest-dom';
2
+ import React from 'react';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { InputContainer } from './InputContainer';
5
+
6
+ describe('InputContainer', () => {
7
+ it('Renders with expected content', () => {
8
+ render(
9
+ <InputContainer name="my-input" label="My input" error="Input is invalid" required={true}>
10
+ input element
11
+ </InputContainer>,
12
+ );
13
+
14
+ expect(screen.getByText('My input')).toHaveClass('squiz-fte-form-label');
15
+ expect(screen.getByText('input element')).toBeInTheDocument();
16
+ expect(screen.getByText('Input is invalid')).toHaveClass('squiz-fte-form-error');
17
+ });
18
+ });
@@ -0,0 +1,29 @@
1
+ import React, { ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+
4
+ export type InputContainerProps = {
5
+ name?: string;
6
+ label?: string;
7
+ error?: string;
8
+ required?: boolean;
9
+ children: ReactNode;
10
+ };
11
+
12
+ export const InputContainer = ({ name, label, error, required, children }: InputContainerProps) => {
13
+ return (
14
+ <div className={clsx(error && 'squiz-fte-invalid-form-field')}>
15
+ {label && (
16
+ <label htmlFor={name} className="squiz-fte-form-label">
17
+ {label}
18
+ </label>
19
+ )}
20
+ {label && required && (
21
+ <span className="text-gray-600" aria-label="Required field">
22
+ *
23
+ </span>
24
+ )}
25
+ {children}
26
+ {error && <div className="squiz-fte-form-error">{error}</div>}
27
+ </div>
28
+ );
29
+ };