@toptal/picasso-rich-text-editor 100.0.10 → 100.1.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 (45) hide show
  1. package/dist-package/src/LexicalEditor/hooks/useOnFocus/use-on-focus.d.ts +1 -0
  2. package/dist-package/src/LexicalEditor/hooks/useOnFocus/use-on-focus.d.ts.map +1 -1
  3. package/dist-package/src/LexicalEditor/hooks/useOnFocus/use-on-focus.js +29 -4
  4. package/dist-package/src/LexicalEditor/hooks/useOnFocus/use-on-focus.js.map +1 -1
  5. package/dist-package/src/LexicalEditor/utils/cleanup-html-output.d.ts.map +1 -1
  6. package/dist-package/src/LexicalEditor/utils/cleanup-html-output.js +10 -1
  7. package/dist-package/src/LexicalEditor/utils/cleanup-html-output.js.map +1 -1
  8. package/dist-package/src/index.d.ts +1 -1
  9. package/dist-package/src/index.d.ts.map +1 -1
  10. package/dist-package/src/index.js.map +1 -1
  11. package/dist-package/src/plugins/LinkPlugin/LinkPlugin.d.ts +5 -3
  12. package/dist-package/src/plugins/LinkPlugin/LinkPlugin.d.ts.map +1 -1
  13. package/dist-package/src/plugins/LinkPlugin/LinkPlugin.js +7 -3
  14. package/dist-package/src/plugins/LinkPlugin/LinkPlugin.js.map +1 -1
  15. package/dist-package/src/plugins/LinkPlugin/LinkPluginButton.d.ts +6 -4
  16. package/dist-package/src/plugins/LinkPlugin/LinkPluginButton.d.ts.map +1 -1
  17. package/dist-package/src/plugins/LinkPlugin/LinkPluginButton.js +5 -47
  18. package/dist-package/src/plugins/LinkPlugin/LinkPluginButton.js.map +1 -1
  19. package/dist-package/src/plugins/LinkPlugin/LinkPluginModal.d.ts +24 -0
  20. package/dist-package/src/plugins/LinkPlugin/LinkPluginModal.d.ts.map +1 -0
  21. package/dist-package/src/plugins/LinkPlugin/LinkPluginModal.js +51 -0
  22. package/dist-package/src/plugins/LinkPlugin/LinkPluginModal.js.map +1 -0
  23. package/dist-package/src/plugins/LinkPlugin/use-link-plugin.d.ts +15 -0
  24. package/dist-package/src/plugins/LinkPlugin/use-link-plugin.d.ts.map +1 -0
  25. package/dist-package/src/plugins/LinkPlugin/use-link-plugin.js +140 -0
  26. package/dist-package/src/plugins/LinkPlugin/use-link-plugin.js.map +1 -0
  27. package/dist-package/src/plugins/index.d.ts +2 -1
  28. package/dist-package/src/plugins/index.d.ts.map +1 -1
  29. package/dist-package/src/plugins/index.js.map +1 -1
  30. package/dist-package/src/utils/html-to-hast.js +1 -1
  31. package/dist-package/src/utils/html-to-hast.js.map +1 -1
  32. package/package.json +4 -2
  33. package/src/LexicalEditor/hooks/useOnFocus/test.ts +115 -1
  34. package/src/LexicalEditor/hooks/useOnFocus/use-on-focus.ts +41 -7
  35. package/src/LexicalEditor/utils/cleanup-html-output.test.ts +9 -0
  36. package/src/LexicalEditor/utils/cleanup-html-output.ts +11 -0
  37. package/src/index.ts +6 -1
  38. package/src/plugins/LinkPlugin/LinkPlugin.tsx +29 -4
  39. package/src/plugins/LinkPlugin/LinkPluginButton.tsx +16 -61
  40. package/src/plugins/LinkPlugin/LinkPluginModal.test.tsx +172 -0
  41. package/src/plugins/LinkPlugin/LinkPluginModal.tsx +142 -0
  42. package/src/plugins/LinkPlugin/use-link-plugin.ts +193 -0
  43. package/src/plugins/index.ts +2 -1
  44. package/src/utils/__tests__/html-to-hast.test.tsx +23 -0
  45. package/src/utils/html-to-hast.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toptal/picasso-rich-text-editor",
3
- "version": "100.0.10",
3
+ "version": "100.1.0",
4
4
  "description": "Picasso rich text editor",
5
5
  "author": "Toptal",
6
6
  "homepage": "https://github.com/toptal/picasso/tree/master/packages/picasso-rich-text-editor#readme",
@@ -46,8 +46,9 @@
46
46
  "lexical": "0.11.3",
47
47
  "@toptal/picasso-container": "100.0.1",
48
48
  "@toptal/picasso-button": "100.0.2",
49
+ "@toptal/picasso-checkbox": "100.1.0",
49
50
  "@toptal/picasso-icons": "100.0.1",
50
- "@toptal/picasso-file-input": "100.0.3",
51
+ "@toptal/picasso-file-input": "100.0.4",
51
52
  "@toptal/picasso-image": "100.0.1",
52
53
  "@toptal/picasso-outlined-input": "100.1.0",
53
54
  "@toptal/picasso-list": "100.0.1",
@@ -78,6 +79,7 @@
78
79
  "dist-package/**",
79
80
  "!dist-package/tsconfig.tsbuildinfo",
80
81
  "src",
82
+ "!src/**/*.figma.ts",
81
83
  "eager.js",
82
84
  "eager.d.ts"
83
85
  ],
@@ -1,6 +1,6 @@
1
1
  import { renderHook, act } from '@testing-library/react-hooks'
2
2
 
3
- import useOnFocus from './use-on-focus'
3
+ import useOnFocus, { INTERNAL_DIALOG_ATTRIBUTE } from './use-on-focus'
4
4
 
5
5
  let mockEvent: React.FocusEvent<HTMLDivElement>
6
6
 
@@ -73,4 +73,118 @@ describe('useOnFocus', () => {
73
73
  })
74
74
  })
75
75
  })
76
+
77
+ it('calls onFocus once while focus stays inside the editor', () => {
78
+ const onFocus = jest.fn()
79
+ const { result } = renderHook(() => useOnFocus({ onFocus }))
80
+
81
+ act(() => {
82
+ result.current.handleFocus(mockEvent)
83
+ result.current.handleFocus(mockEvent)
84
+ })
85
+
86
+ expect(onFocus).toHaveBeenCalledTimes(1)
87
+ })
88
+
89
+ it('does not blur when focus moves into a plugin dialog', () => {
90
+ const onBlur = jest.fn()
91
+ const dialog = document.createElement('div')
92
+ const input = document.createElement('input')
93
+
94
+ dialog.setAttribute(INTERNAL_DIALOG_ATTRIBUTE, '')
95
+ dialog.appendChild(input)
96
+
97
+ const { result } = renderHook(() => useOnFocus({ onBlur }))
98
+
99
+ act(() => {
100
+ result.current.handleFocus(mockEvent)
101
+ })
102
+ act(() => {
103
+ result.current.handleBlur(getFocusEvent(input))
104
+ })
105
+
106
+ expect(result.current.focused).toBe(true)
107
+ expect(onBlur).not.toHaveBeenCalled()
108
+ })
109
+
110
+ it('calls onFocus again after a blur', () => {
111
+ const onFocus = jest.fn()
112
+ const { result } = renderHook(() => useOnFocus({ onFocus }))
113
+
114
+ act(() => {
115
+ result.current.handleFocus(mockEvent)
116
+ })
117
+ act(() => {
118
+ result.current.handleBlur(mockEvent)
119
+ })
120
+ act(() => {
121
+ result.current.handleFocus(mockEvent)
122
+ })
123
+
124
+ expect(onFocus).toHaveBeenCalledTimes(2)
125
+ })
126
+
127
+ it("does not blur when focus moves to the dialog's focus guard", () => {
128
+ const onBlur = jest.fn()
129
+ const portal = document.createElement('div')
130
+ const guard = document.createElement('span')
131
+ const dialog = document.createElement('div')
132
+
133
+ guard.setAttribute('data-base-ui-focus-guard', '')
134
+ dialog.setAttribute(INTERNAL_DIALOG_ATTRIBUTE, '')
135
+ portal.append(guard, dialog)
136
+
137
+ const { result } = renderHook(() => useOnFocus({ onBlur }))
138
+
139
+ act(() => {
140
+ result.current.handleFocus(mockEvent)
141
+ })
142
+ act(() => {
143
+ result.current.handleBlur(getFocusEvent(guard))
144
+ })
145
+
146
+ expect(onBlur).not.toHaveBeenCalled()
147
+ })
148
+
149
+ it('blurs when focus moves to a focus guard of another dialog', () => {
150
+ const onBlur = jest.fn()
151
+ const portal = document.createElement('div')
152
+ const guard = document.createElement('span')
153
+
154
+ guard.setAttribute('data-base-ui-focus-guard', '')
155
+ portal.append(guard, document.createElement('div'))
156
+
157
+ const { result } = renderHook(() => useOnFocus({ onBlur }))
158
+
159
+ act(() => {
160
+ result.current.handleFocus(mockEvent)
161
+ })
162
+ act(() => {
163
+ result.current.handleBlur(getFocusEvent(guard))
164
+ })
165
+
166
+ expect(onBlur).toHaveBeenCalledTimes(1)
167
+ })
168
+
169
+ it('does not blur when focus moves within the editor', () => {
170
+ const onBlur = jest.fn()
171
+ const wrapper = document.createElement('div')
172
+ const content = document.createElement('div')
173
+
174
+ wrapper.appendChild(content)
175
+
176
+ const { result } = renderHook(() => useOnFocus({ onBlur }))
177
+
178
+ act(() => {
179
+ result.current.handleFocus(mockEvent)
180
+ })
181
+ act(() => {
182
+ result.current.handleBlur({
183
+ relatedTarget: content,
184
+ currentTarget: wrapper,
185
+ } as unknown as React.FocusEvent<HTMLDivElement>)
186
+ })
187
+
188
+ expect(onBlur).not.toHaveBeenCalled()
189
+ })
76
190
  })
@@ -1,6 +1,10 @@
1
- import { useCallback, useState } from 'react'
1
+ import { useCallback, useRef, useState } from 'react'
2
2
  import { noop } from '@toptal/picasso-utils'
3
3
 
4
+ // Plugin dialogs render in a portal outside the editor, but moving focus into
5
+ // one is still part of editing, so it must not blur the editor
6
+ export const INTERNAL_DIALOG_ATTRIBUTE = 'data-rte-dialog'
7
+
4
8
  export type Props = {
5
9
  internalRefs?: React.RefObject<HTMLDivElement>[]
6
10
  onFocus?: () => void
@@ -13,29 +17,59 @@ type Result = {
13
17
  handleBlur: (e: React.FocusEvent<HTMLDivElement>) => void
14
18
  }
15
19
 
20
+ const isDialogElement = (element: Element) =>
21
+ Boolean(element.closest(`[${INTERNAL_DIALOG_ATTRIBUTE}]`)) ||
22
+ // Base UI puts the dialog's focus guards beside it, not inside it
23
+ (element.hasAttribute('data-base-ui-focus-guard') &&
24
+ Boolean(
25
+ element.parentElement?.querySelector(
26
+ `:scope > [${INTERNAL_DIALOG_ATTRIBUTE}]`
27
+ )
28
+ ))
29
+
30
+ const isInternalElement = (
31
+ e: React.FocusEvent<HTMLDivElement>,
32
+ internalRefs: React.RefObject<HTMLDivElement>[]
33
+ ) => {
34
+ const focusElement = e.relatedTarget as Node | null
35
+
36
+ if (!focusElement) {
37
+ return false
38
+ }
39
+
40
+ return (
41
+ Boolean(e.currentTarget?.contains(focusElement)) ||
42
+ internalRefs.some(ref => ref.current?.contains(focusElement)) ||
43
+ (focusElement instanceof Element && isDialogElement(focusElement))
44
+ )
45
+ }
46
+
16
47
  const useOnFocus = ({
17
48
  onFocus = noop,
18
49
  onBlur = noop,
19
50
  internalRefs = [],
20
51
  }: Props): Result => {
21
52
  const [focused, setFocused] = useState(false)
53
+ // Focus coming back from the toolbar or a dialog is not a new focus
54
+ const focusedRef = useRef(false)
22
55
 
23
56
  const handleFocus = useCallback(() => {
57
+ if (focusedRef.current) {
58
+ return
59
+ }
60
+
61
+ focusedRef.current = true
24
62
  setFocused(true)
25
63
  onFocus()
26
64
  }, [onFocus])
27
65
 
28
66
  const handleBlur = useCallback(
29
67
  (e: React.FocusEvent<HTMLDivElement>) => {
30
- const focusElement = e.relatedTarget as Node
31
- const isInternalElement = internalRefs.some(
32
- ref => ref.current && ref.current.contains(focusElement)
33
- )
34
-
35
- if (isInternalElement) {
68
+ if (isInternalElement(e, internalRefs)) {
36
69
  return
37
70
  }
38
71
 
72
+ focusedRef.current = false
39
73
  setFocused(false)
40
74
  onBlur()
41
75
  },
@@ -82,4 +82,13 @@ describe('cleanupHtmlOutput', () => {
82
82
 
83
83
  expect(result).toBe(expected)
84
84
  })
85
+
86
+ it('adds a safe rel to links that open in a new tab', () => {
87
+ const html =
88
+ '<p><a href="https://toptal.com" target="_blank">new tab</a><a href="https://toptal.com" target="_blank" rel="opener">opener</a><a href="https://toptal.com" rel="noreferrer">same tab</a></p>'
89
+
90
+ expect(cleanupHtmlOutput(html)).toBe(
91
+ '<p><a href="https://toptal.com" target="_blank" rel="noopener noreferrer">new tab</a><a href="https://toptal.com" target="_blank" rel="noopener noreferrer">opener</a><a href="https://toptal.com" rel="noreferrer">same tab</a></p>'
92
+ )
93
+ })
85
94
  })
@@ -112,6 +112,16 @@ const hoistNestedLists = (htmlDoc: Document): Document => {
112
112
  return htmlDoc
113
113
  }
114
114
 
115
+ // A link that opens in a new tab must not get access to this page through
116
+ // window.opener, whatever produced it (the dialog or pasted HTML)
117
+ const secureNewTabLinks = (htmlDoc: Document): Document => {
118
+ htmlDoc.querySelectorAll('a[target="_blank"]').forEach(link => {
119
+ link.setAttribute('rel', 'noopener noreferrer')
120
+ })
121
+
122
+ return htmlDoc
123
+ }
124
+
115
125
  export const cleanupHtmlOutput = (html: string): string => {
116
126
  const parser = new DOMParser()
117
127
 
@@ -121,6 +131,7 @@ export const cleanupHtmlOutput = (html: string): string => {
121
131
  .map(removeExtraTags)
122
132
  .map(replaceItalicTag)
123
133
  .map(hoistNestedLists)
134
+ .map(secureNewTabLinks)
124
135
 
125
136
  const result = newHtml.body.innerHTML
126
137
 
package/src/index.ts CHANGED
@@ -12,5 +12,10 @@ export {
12
12
  CodePlugin,
13
13
  CodeBlockPlugin,
14
14
  } from './plugins'
15
- export type { UploadedImage, CustomEmoji, CustomEmojiGroup } from './plugins'
15
+ export type {
16
+ UploadedImage,
17
+ CustomEmoji,
18
+ CustomEmojiGroup,
19
+ LinkPluginProps,
20
+ } from './plugins'
16
21
  export * from './utils'
@@ -1,24 +1,49 @@
1
1
  import { LinkNode } from '@lexical/link'
2
2
  import { LinkPlugin as LexicalLinkPlugin } from '@lexical/react/LexicalLinkPlugin'
3
3
  import React from 'react'
4
+ import type { BaseProps } from '@toptal/picasso-shared'
4
5
 
5
6
  import type { RTEPlugin } from '../api'
6
7
  import { RTEPluginMeta, Toolbar } from '../api'
7
8
  import LinkPluginButton from './LinkPluginButton'
9
+ import LinkPluginModal from './LinkPluginModal'
10
+ import { useLinkPlugin } from './use-link-plugin'
8
11
 
9
12
  const PLUGIN_NAME = 'link'
10
13
 
11
- export type Props = {
12
- 'data-testid'?: string
14
+ export interface Props extends BaseProps {
15
+ /** Where a new link opens: `_blank` checks "Open in new tab" in the dialog */
16
+ defaultTarget?: '_self' | '_blank'
13
17
  }
14
18
 
15
- const LinkPlugin: RTEPlugin<Props> = ({ 'data-testid': testId }: Props) => {
19
+ const LinkPlugin: RTEPlugin<Props> = ({
20
+ defaultTarget = '_self',
21
+ className,
22
+ style,
23
+ 'data-testid': testId,
24
+ }: Props) => {
25
+ const { open, editing, initialValues, show, close, submit, remove } =
26
+ useLinkPlugin({ defaultTarget })
27
+
16
28
  return (
17
29
  <>
18
30
  <Toolbar keyName={PLUGIN_NAME}>
19
- <LinkPluginButton data-testid={testId} />
31
+ <LinkPluginButton
32
+ onClick={show}
33
+ className={className}
34
+ style={style}
35
+ data-testid={testId}
36
+ />
20
37
  </Toolbar>
21
38
  <LexicalLinkPlugin />
39
+ <LinkPluginModal
40
+ open={open}
41
+ editing={editing}
42
+ initialValues={initialValues}
43
+ onClose={close}
44
+ onSubmit={submit}
45
+ onRemove={remove}
46
+ />
22
47
  </>
23
48
  )
24
49
  }
@@ -1,25 +1,25 @@
1
- import {
2
- $isLinkNode,
3
- TOGGLE_LINK_COMMAND,
4
- $createLinkNode,
5
- } from '@lexical/link'
6
- import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
1
+ import { $isLinkNode } from '@lexical/link'
7
2
  import { Link16 } from '@toptal/picasso-icons'
8
- import { $createTextNode, $getSelection, $isRangeSelection } from 'lexical'
9
- import React, { useCallback, useState } from 'react'
3
+ import type { BaseProps } from '@toptal/picasso-shared'
4
+ import { $getSelection, $isRangeSelection } from 'lexical'
5
+ import React, { useState } from 'react'
10
6
 
11
7
  import { getSelectedNode } from '../../LexicalEditor/utils/get-selected-node'
12
8
  import { useRTEPluginContext, useRTEUpdate } from '../api'
13
9
  import RichTextEditorButton from '../../RichTextEditorButton'
14
- import { sanitizeUrl, validateUrl } from './utils/url'
15
10
 
16
- export type Props = {
17
- 'data-testid'?: string
11
+ export interface Props extends BaseProps {
12
+ /** Called when the button is clicked */
13
+ onClick: () => void
18
14
  }
19
15
 
20
- const LinkPluginButton = ({ 'data-testid': testId }: Props) => {
16
+ const LinkPluginButton = ({
17
+ 'data-testid': testId,
18
+ className,
19
+ style,
20
+ onClick,
21
+ }: Props) => {
21
22
  const [active, setActive] = useState(false)
22
- const [editor] = useLexicalComposerContext()
23
23
  const { disabled, focused, disabledFormatting } = useRTEPluginContext()
24
24
 
25
25
  useRTEUpdate(() => {
@@ -33,61 +33,16 @@ const LinkPluginButton = ({ 'data-testid': testId }: Props) => {
33
33
  }
34
34
  })
35
35
 
36
- const onLinkClick = useCallback(() => {
37
- if (active) {
38
- return editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
39
- }
40
-
41
- editor.update(() => {
42
- const selection = $getSelection()
43
-
44
- if ($isRangeSelection(selection)) {
45
- const isEmptySelection = selection.anchor.is(selection.focus)
46
-
47
- const url = window.prompt('URL')
48
-
49
- if (url != null) {
50
- if (!validateUrl(url)) {
51
- return window.alert('Not a valid URL')
52
- }
53
- const sanitizedUrl = sanitizeUrl(url)
54
-
55
- // When nothing is selected, we create a new Link node without dispatching
56
- // any commands to the original Lexical Link plugin
57
- if (isEmptySelection) {
58
- // The only way to reliably insert a link is to first create a dummy text node
59
- selection.insertNodes([$createTextNode(sanitizedUrl)])
60
- // Once created, node becomes selected
61
- const node = getSelectedNode(selection)
62
- const text = node.getTextContent()
63
-
64
- // Then we create a target Link node and replace the dummy text node with it
65
- const linkNode = $createLinkNode(sanitizedUrl, {
66
- rel: 'noreferrer',
67
- })
68
-
69
- linkNode.append($createTextNode(text))
70
-
71
- node.replace(linkNode)
72
- } else {
73
- // If we have a selection of any kind, pass the creation of the Link node to the plugin
74
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
75
- url: sanitizedUrl,
76
- })
77
- }
78
- }
79
- }
80
- })
81
- }, [editor, active])
82
-
83
36
  const isDisabled = disabled || !focused || disabledFormatting
84
37
 
85
38
  return (
86
39
  <RichTextEditorButton
87
40
  icon={<Link16 />}
88
- onClick={onLinkClick}
41
+ onClick={onClick}
89
42
  active={isDisabled ? false : active}
90
43
  disabled={isDisabled}
44
+ className={className}
45
+ style={style}
91
46
  data-testid={testId}
92
47
  />
93
48
  )
@@ -0,0 +1,172 @@
1
+ import React from 'react'
2
+ import { fireEvent, render, screen } from '@toptal/picasso-test-utils'
3
+
4
+ import type { Props } from './LinkPluginModal'
5
+ import LinkPluginModal from './LinkPluginModal'
6
+
7
+ const renderModal = (props: Partial<Props> = {}) => {
8
+ const handlers = {
9
+ onClose: jest.fn(),
10
+ onSubmit: jest.fn(),
11
+ onRemove: jest.fn(),
12
+ }
13
+
14
+ render(
15
+ <LinkPluginModal
16
+ open
17
+ editing={false}
18
+ initialValues={{ text: '', url: '', openInNewTab: false }}
19
+ {...handlers}
20
+ {...props}
21
+ />
22
+ )
23
+
24
+ return handlers
25
+ }
26
+
27
+ const type = (label: string, value: string) =>
28
+ fireEvent.change(screen.getByLabelText(label), { target: { value } })
29
+
30
+ describe('LinkPluginModal', () => {
31
+ it('submits the text, the URL and whether to open in a new tab', () => {
32
+ const { onSubmit } = renderModal()
33
+
34
+ type('Text', 'Book a call')
35
+ type('Link', ' https://toptal.com/book ')
36
+ fireEvent.click(screen.getByLabelText('Open in new tab'))
37
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
38
+
39
+ expect(onSubmit).toHaveBeenCalledWith({
40
+ text: 'Book a call',
41
+ url: 'https://toptal.com/book',
42
+ openInNewTab: true,
43
+ })
44
+ })
45
+
46
+ it('opens links in the same tab unless chosen otherwise', () => {
47
+ const { onSubmit } = renderModal()
48
+
49
+ type('Link', 'https://toptal.com')
50
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
51
+
52
+ expect(onSubmit).toHaveBeenCalledWith(
53
+ expect.objectContaining({ openInNewTab: false })
54
+ )
55
+ })
56
+
57
+ it('rejects an invalid URL', () => {
58
+ const { onSubmit } = renderModal()
59
+
60
+ type('Link', 'not a url')
61
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
62
+
63
+ expect(onSubmit).not.toHaveBeenCalled()
64
+ expect(screen.getByText('Enter a valid URL')).toBeInTheDocument()
65
+ })
66
+
67
+ it('disables saving until a URL is entered', () => {
68
+ renderModal()
69
+
70
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
71
+ })
72
+
73
+ it('prefills an edited link and offers to remove it', () => {
74
+ const { onRemove } = renderModal({
75
+ editing: true,
76
+ initialValues: {
77
+ text: 'Toptal',
78
+ url: 'https://toptal.com',
79
+ openInNewTab: true,
80
+ },
81
+ })
82
+
83
+ expect(screen.getByText('Edit link')).toBeInTheDocument()
84
+ expect(screen.getByLabelText('Text')).toHaveValue('Toptal')
85
+ expect(screen.getByLabelText('Link')).toHaveValue('https://toptal.com')
86
+ expect(screen.getByLabelText('Open in new tab')).toBeChecked()
87
+
88
+ fireEvent.click(screen.getByRole('button', { name: 'Remove link' }))
89
+
90
+ expect(onRemove).toHaveBeenCalled()
91
+ })
92
+
93
+ it('does not offer removing a new link', () => {
94
+ renderModal()
95
+
96
+ expect(screen.getByText('Add link')).toBeInTheDocument()
97
+ expect(
98
+ screen.queryByRole('button', { name: 'Remove link' })
99
+ ).not.toBeInTheDocument()
100
+ })
101
+
102
+ it('focuses the link field when text is already selected', () => {
103
+ renderModal({
104
+ initialValues: { text: 'Toptal', url: '', openInNewTab: false },
105
+ })
106
+
107
+ expect(screen.getByLabelText('Link')).toHaveFocus()
108
+ })
109
+
110
+ it('focuses the text field when nothing is selected', () => {
111
+ renderModal()
112
+
113
+ expect(screen.getByLabelText('Text')).toHaveFocus()
114
+ })
115
+
116
+ it('resets the form when it opens with new values', () => {
117
+ const props = {
118
+ open: true,
119
+ editing: false,
120
+ onClose: jest.fn(),
121
+ onSubmit: jest.fn(),
122
+ onRemove: jest.fn(),
123
+ }
124
+ const { rerender } = render(
125
+ <LinkPluginModal
126
+ {...props}
127
+ initialValues={{ text: 'Old', url: '', openInNewTab: false }}
128
+ />
129
+ )
130
+
131
+ type('Link', 'not a url')
132
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
133
+
134
+ rerender(
135
+ <LinkPluginModal
136
+ {...props}
137
+ initialValues={{ text: 'New', url: '', openInNewTab: true }}
138
+ />
139
+ )
140
+
141
+ expect(screen.getByLabelText('Text')).toHaveValue('New')
142
+ expect(screen.getByLabelText('Link')).toHaveValue('')
143
+ expect(screen.getByLabelText('Open in new tab')).toBeChecked()
144
+ expect(screen.queryByText('Enter a valid URL')).not.toBeInTheDocument()
145
+ })
146
+
147
+ it('focuses the link field when it reopens with selected text', () => {
148
+ const props = {
149
+ editing: false,
150
+ onClose: jest.fn(),
151
+ onSubmit: jest.fn(),
152
+ onRemove: jest.fn(),
153
+ }
154
+ const { rerender } = render(
155
+ <LinkPluginModal
156
+ {...props}
157
+ open={false}
158
+ initialValues={{ text: '', url: '', openInNewTab: false }}
159
+ />
160
+ )
161
+
162
+ rerender(
163
+ <LinkPluginModal
164
+ {...props}
165
+ open
166
+ initialValues={{ text: 'Toptal', url: '', openInNewTab: false }}
167
+ />
168
+ )
169
+
170
+ expect(screen.getByLabelText('Link')).toHaveFocus()
171
+ })
172
+ })