@estiva-app/ui 0.69.2 → 0.71.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.69.2",
3
+ "version": "0.71.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.69.2",
8
- "exports": 162,
9
- "typeExports": 152,
7
+ "packageVersion": "0.71.0",
8
+ "exports": 166,
9
+ "typeExports": 155,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -2335,9 +2335,9 @@
2335
2335
  },
2336
2336
  {
2337
2337
  "name": "turnInto",
2338
- "takes": "readonly BlockTurnInto[]",
2338
+ "takes": "a handler",
2339
2339
  "required": false,
2340
- "note": "Turn into's rows. Offered for a block of text — a paragraph, a heading, code."
2340
+ "note": "Turn into's rows for a block. By default `turnIntoRows`, the same in every app: a block of text, a list and a quote each get theirs."
2341
2341
  },
2342
2342
  {
2343
2343
  "name": "actions",
@@ -4819,6 +4819,25 @@
4819
4819
  "app": null,
4820
4820
  "looks": []
4821
4821
  },
4822
+ {
4823
+ "name": "PastedMarkdown",
4824
+ "repo": "estiva-ui",
4825
+ "kind": "component",
4826
+ "importPath": "@estiva-app/ui/editor",
4827
+ "sourceFile": "src/PastedMarkdown.ts",
4828
+ "purpose": "Markdown pasted into an editor as plain text lands as what it spells: a heading, a list, a checklist, a quote or callout, a table.",
4829
+ "purposeFrom": "page",
4830
+ "props": [],
4831
+ "variants": [],
4832
+ "ownsBehaviours": [],
4833
+ "status": "stable",
4834
+ "migrationStage": null,
4835
+ "docsId": "primitives-pastedmarkdown--docs",
4836
+ "storyId": "primitives-pastedmarkdown--in-an-editor",
4837
+ "docPage": "src/PastedMarkdown.mdx",
4838
+ "app": null,
4839
+ "looks": []
4840
+ },
4822
4841
  {
4823
4842
  "name": "peopleMenu",
4824
4843
  "repo": "estiva-ui",
@@ -7767,6 +7786,63 @@
7767
7786
  "truncate text-left text-input-value text-text-muted"
7768
7787
  ]
7769
7788
  },
7789
+ {
7790
+ "name": "turnIntoRows",
7791
+ "repo": "estiva-ui",
7792
+ "kind": "helper",
7793
+ "importPath": "@estiva-app/ui/editor",
7794
+ "sourceFile": "src/EditorBlockHandle.tsx",
7795
+ "purpose": "The block menu's Turn into for one block, the same in every app (Miky, LEAF-11's kickoff, 15 October):",
7796
+ "purposeFrom": "comment",
7797
+ "props": [],
7798
+ "variants": [],
7799
+ "ownsBehaviours": [],
7800
+ "status": "stable",
7801
+ "migrationStage": null,
7802
+ "docsId": "primitives-editorblockhandle--docs",
7803
+ "storyId": "primitives-editorblockhandle--default",
7804
+ "docPage": null,
7805
+ "app": null,
7806
+ "looks": []
7807
+ },
7808
+ {
7809
+ "name": "turnListInto",
7810
+ "repo": "estiva-ui",
7811
+ "kind": "helper",
7812
+ "importPath": "@estiva-app/ui/editor",
7813
+ "sourceFile": "src/EditorBlockHandle.tsx",
7814
+ "purpose": "Turns the whole list at `pos`, every item, the nested ones too.",
7815
+ "purposeFrom": "comment",
7816
+ "props": [],
7817
+ "variants": [],
7818
+ "ownsBehaviours": [],
7819
+ "status": "stable",
7820
+ "migrationStage": null,
7821
+ "docsId": "primitives-editorblockhandle--docs",
7822
+ "storyId": "primitives-editorblockhandle--default",
7823
+ "docPage": null,
7824
+ "app": null,
7825
+ "looks": []
7826
+ },
7827
+ {
7828
+ "name": "turnQuoteInto",
7829
+ "repo": "estiva-ui",
7830
+ "kind": "helper",
7831
+ "importPath": "@estiva-app/ui/editor",
7832
+ "sourceFile": "src/EditorBlockHandle.tsx",
7833
+ "purpose": "Turns the quote at `pos`: a plain quote, a Note or Warning callout (the tone changes, the id stays), or its text, whose first paragraph takes the quote's id, as lifting it out does (MAN-12).",
7834
+ "purposeFrom": "comment",
7835
+ "props": [],
7836
+ "variants": [],
7837
+ "ownsBehaviours": [],
7838
+ "status": "stable",
7839
+ "migrationStage": null,
7840
+ "docsId": "primitives-editorblockhandle--docs",
7841
+ "storyId": "primitives-editorblockhandle--default",
7842
+ "docPage": null,
7843
+ "app": null,
7844
+ "looks": []
7845
+ },
7770
7846
  {
7771
7847
  "name": "typeTrigger",
7772
7848
  "repo": "estiva-ui",
@@ -178,7 +178,7 @@ describe('BlockHandle', () => {
178
178
  // jsdom lays nothing out: every block's top is 0, so the pointer is on the last.
179
179
  await act(async () => user.hover(screen.getByText('two')))
180
180
  await press(user, screen.getByRole('button', { name: 'Block menu' }))
181
- expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Duplicate', 'Delete'])
181
+ expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Turn into', 'Duplicate', 'Delete'])
182
182
  await user.click(screen.getByRole('menuitem', { name: 'Delete' }))
183
183
  // The editor takes the focus back a frame later and measures the caret then: wait for it, so it is part of the test.
184
184
  await act(() => new Promise<void>((done) => requestAnimationFrame(() => done())))
@@ -474,6 +474,8 @@ export const ReferenceNode = Node.create<ReferenceNodeOptions>({
474
474
  * Not editable, deliberately: an affordance that lets a person change
475
475
  * something they cannot see is worse than one that says the content is there
476
476
  * and belongs to another surface.
477
+ *
478
+ * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/the-block-editor-kit-lives-in-protocol-and-ui.md
477
479
  */
478
480
  export const UnknownBlock = Node.create({
479
481
  name: 'unknownBlock',
@@ -21,6 +21,8 @@ import { suggestionPopup, type SuggestionPopupOptions } from './suggestionPopup'
21
21
  * `messageReference`, `urgentTagsFor`), so a client without React writes the
22
22
  * same bytes. A chip here carries what that needs: a person's `pubkey`, a
23
23
  * reference's `uri`.
24
+ *
25
+ * why: https://github.com/estiva-app/estiva-docs/blob/main/decisions/composer-triggers-are-one-shared-kit.md
24
26
  */
25
27
 
26
28
  // ─── @ and !@ ───
@@ -482,7 +484,7 @@ export const FORMATS: readonly FormatDef[] = [
482
484
  * `Callout` extensions. Note and Warning are the two callouts offered (Miky,
483
485
  * 2026-10-13); the other tones are read and kept, not offered.
484
486
  */
485
- const DOCUMENT_FORMATS: readonly FormatDef[] = [
487
+ export const DOCUMENT_FORMATS: readonly FormatDef[] = [
486
488
  { id: 'checklist', label: 'Checklist', trigger: '[]' },
487
489
  { id: 'note', label: 'Note' },
488
490
  { id: 'warning', label: 'Warning' },
@@ -28,24 +28,29 @@ for Turn into, Duplicate and Delete, then the app's rows.
28
28
  ```tsx
29
29
  import { EditorContent, useEditor } from '@tiptap/react'
30
30
  import StarterKit from '@tiptap/starter-kit'
31
- import { BlockId, EditorBlockHandle, type BlockTurnInto } from '@estiva-app/ui/editor'
31
+ import { BlockId, Callout, Checklist, EditorBlockHandle } from '@estiva-app/ui/editor'
32
32
  import { MenuItem, type HandleBlock } from '@estiva-app/ui'
33
33
 
34
- const turnInto: BlockTurnInto[] = [
35
- { label: 'Text', run: (editor) => editor.chain().setParagraph().run(), active: (node) => node.type.name === 'paragraph' },
36
- { label: 'Heading', run: (editor) => editor.chain().setHeading({ level: 1 }).run() },
37
- ]
38
34
  const rows = (block: HandleBlock) => <MenuItem label="Copy link" onClick={() => navigator.clipboard.writeText(`#${block.id}`)} />
39
35
 
40
- const editor = useEditor({ extensions: [StarterKit, BlockId] })
36
+ const editor = useEditor({ extensions: [StarterKit, BlockId, Checklist, Callout] })
41
37
 
42
- <EditorBlockHandle editor={editor} turnInto={turnInto} actions={rows}>
38
+ <EditorBlockHandle editor={editor} actions={rows}>
43
39
  <EditorContent editor={editor} />
44
40
  </EditorBlockHandle>
45
41
  ```
46
42
 
47
- - `turnInto`'s `run` gets the editor with the caret already in the block, and
48
- is offered only for a block of text: a paragraph, a heading, code.
43
+ - **Turn into is the same in every app** (`turnIntoRows`): a block of text gets the `/` menu's formats, code as a block, and
44
+ Checklist, Note and Warning; a list gets Bulleted list, Numbered list,
45
+ Checklist and Text; a quote gets Quote, Note, Warning and Text. Checklist
46
+ and the callouts appear only where the editor has `Checklist` and `Callout`.
47
+ - **A list or a quote turns whole**, and keeps its id and every item's, so a
48
+ comment anchored to it stays: Checklist keeps the ticks there are, Bulleted
49
+ and Numbered drop them, Text makes each item a paragraph under the item's
50
+ id. `turnListInto` and `turnQuoteInto` are those rows' actions.
51
+ - `turnInto` takes other rows: a function of the block, or a list offered for
52
+ a block of text only, its `run` getting the editor with the caret already in
53
+ the block. `[]` offers none.
49
54
  - **Every row leaves the editor focused**, as a drop does: Turn into in the
50
55
  block, Duplicate in the copy, Delete where the block was. An editor that
51
56
  saves when the person leaves it saves these the same way.
@@ -3,8 +3,8 @@ import { EditorContent, useEditor } from '@tiptap/react'
3
3
  import StarterKit from '@tiptap/starter-kit'
4
4
  import { IconLink, IconMessage } from '@tabler/icons-react'
5
5
  import type { HandleBlock } from './BlockHandle'
6
- import { BlockId } from './BlockNodes'
7
- import { EditorBlockHandle, blockDropCursor, type BlockTurnInto } from './EditorBlockHandle'
6
+ import { BlockId, Callout, Checklist } from './BlockNodes'
7
+ import { EditorBlockHandle, blockDropCursor } from './EditorBlockHandle'
8
8
  import { MenuItem } from './Menu'
9
9
  import { richTextClassName } from './RichText'
10
10
 
@@ -35,17 +35,11 @@ const DOCUMENT = {
35
35
  { type: 'listItem', attrs: { blockId: 'b6' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item two' }] }] },
36
36
  ],
37
37
  },
38
+ { type: 'blockquote', attrs: { blockId: 'b8' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A quote. Turn it into a Note or a Warning.' }] }] },
38
39
  { type: 'paragraph', attrs: { blockId: 'b7' }, content: [{ type: 'text', text: 'The last paragraph.' }] },
39
40
  ],
40
41
  }
41
42
 
42
- const TURN_INTO: BlockTurnInto[] = [
43
- { label: 'Text', run: (editor) => editor.chain().setParagraph().run(), active: (node) => node.type.name === 'paragraph' },
44
- { label: 'Heading', run: (editor) => editor.chain().setHeading({ level: 1 }).run(), active: (node) => node.type.name === 'heading' && node.attrs.level === 1 },
45
- { label: 'Subheading', run: (editor) => editor.chain().setHeading({ level: 2 }).run(), active: (node) => node.type.name === 'heading' && node.attrs.level === 2 },
46
- { label: 'Code', run: (editor) => editor.chain().setCodeBlock().run(), active: (node) => node.type.name === 'codeBlock' },
47
- ]
48
-
49
43
  /** The rows an app adds: here they only say which block they were pressed on. */
50
44
  function rows(block: HandleBlock) {
51
45
  return (
@@ -58,13 +52,13 @@ function rows(block: HandleBlock) {
58
52
 
59
53
  function Editing({ editable }: { editable: boolean }) {
60
54
  const editor = useEditor({
61
- extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), BlockId],
55
+ extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), BlockId, Checklist, Callout],
62
56
  content: DOCUMENT,
63
57
  editable,
64
58
  editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
65
59
  })
66
60
  return (
67
- <EditorBlockHandle editor={editor} turnInto={TURN_INTO} actions={rows} className="ml-8 max-w-[560px]">
61
+ <EditorBlockHandle editor={editor} actions={rows} className="ml-8 max-w-[560px]">
68
62
  <EditorContent editor={editor} />
69
63
  </EditorBlockHandle>
70
64
  )
@@ -0,0 +1,200 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The block menu's Turn into (LEAF-11): what each row makes of a list, a quote
4
+ * and a block of text, and that the block keeps its id — what a comment
5
+ * anchored to it is attached by.
6
+ */
7
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest'
8
+ import { act, cleanup, render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { Editor, type Extensions } from '@tiptap/core'
11
+ import StarterKit from '@tiptap/starter-kit'
12
+ import { BlockId, Callout, Checklist } from './BlockNodes'
13
+ import { EditorBlockHandle, turnIntoRows, turnListInto, turnQuoteInto } from './EditorBlockHandle'
14
+
15
+ type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[]; text?: string }
16
+
17
+ const item = (id: string, text: string, attrs: Record<string, unknown> = {}, nested?: Json): Json => ({
18
+ type: 'listItem',
19
+ attrs: { blockId: id, ...attrs },
20
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }, ...(nested ? [nested] : [])],
21
+ })
22
+ const para = (id: string, text: string): Json => ({ type: 'paragraph', attrs: { blockId: id }, content: [{ type: 'text', text }] })
23
+ const quote = (id: string, text: string, tone: string | null = null): Json => ({ type: 'blockquote', attrs: { blockId: id, tone }, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] })
24
+
25
+ const editors: Editor[] = []
26
+ function editor(content: Json[], extensions: Extensions = [StarterKit, BlockId, Checklist, Callout]) {
27
+ const e = new Editor({ extensions, content: { type: 'doc', content } })
28
+ editors.push(e)
29
+ return e
30
+ }
31
+ /** The document's blocks, less the empty paragraph StarterKit keeps after a last list or quote. */
32
+ const blocks = (e: Editor) => (e.getJSON().content as Json[]).filter((b, i, all) => !(i === all.length - 1 && b.type === 'paragraph' && !b.content))
33
+ const posOf = (e: Editor, id: string) => {
34
+ let at = -1
35
+ e.state.doc.forEach((node, pos) => {
36
+ if (node.attrs.blockId === id) at = pos
37
+ })
38
+ return at
39
+ }
40
+ /** Each item of the list as `id text checked`, the nested ones after their parent. */
41
+ const items = (list: Json): string[] =>
42
+ (list.content ?? []).flatMap((i) => [
43
+ `${i.attrs?.blockId} ${i.content?.[0].content?.[0].text} ${i.attrs?.checked ?? '-'}`,
44
+ ...(i.content ?? []).slice(1).flatMap(items),
45
+ ])
46
+ const texts = (e: Editor) => blocks(e).map((b) => `${b.type} ${b.attrs?.blockId} ${b.content?.[0]?.content?.[0]?.text ?? b.content?.[0]?.text}`)
47
+
48
+ beforeAll(() => {
49
+ const none = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }
50
+ Range.prototype.getClientRects = () => [] as unknown as DOMRectList
51
+ Range.prototype.getBoundingClientRect = () => none as DOMRect
52
+ })
53
+
54
+ afterEach(() => {
55
+ cleanup()
56
+ for (const e of editors.splice(0)) e.destroy()
57
+ })
58
+
59
+ describe('turnListInto', () => {
60
+ const LIST: Json = { type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'milk', { checked: true }), item('i2', 'eggs', {}, { type: 'bulletList', content: [item('i3', 'brown')] })] }
61
+
62
+ it('a list into a checklist makes every item one, nested ones too, keeping ticks and every id', () => {
63
+ const e = editor([LIST])
64
+ expect(turnListInto(e, 0, 'checklist')).toBe(true)
65
+ const [list] = blocks(e)
66
+ expect(list.type).toBe('bulletList')
67
+ expect(list.attrs?.blockId).toBe('l1')
68
+ expect(items(list)).toEqual(['i1 milk true', 'i2 eggs false', 'i3 brown false'])
69
+ })
70
+
71
+ it('a checklist into a numbered list keeps the list’s id and drops every tick', () => {
72
+ const e = editor([LIST])
73
+ turnListInto(e, 0, 'checklist')
74
+ turnListInto(e, 0, 'orderedList')
75
+ const [list] = blocks(e)
76
+ expect(list.type).toBe('orderedList')
77
+ expect(list.attrs?.blockId).toBe('l1')
78
+ expect(list.content?.[1].content?.[1].type).toBe('orderedList')
79
+ expect(items(list)).toEqual(['i1 milk -', 'i2 eggs -', 'i3 brown -'])
80
+ })
81
+
82
+ it('does not join a list into a numbered list beside it, as Tiptap’s toggle would', () => {
83
+ const e = editor([LIST, { type: 'orderedList', attrs: { blockId: 'l2' }, content: [item('i9', 'after')] }])
84
+ turnListInto(e, 0, 'orderedList')
85
+ expect(blocks(e).map((b) => `${b.type} ${b.attrs?.blockId}`)).toEqual(['orderedList l1', 'orderedList l2'])
86
+ })
87
+
88
+ it('a list into text makes each item a paragraph with the item’s id, in order', () => {
89
+ const e = editor([para('p0', 'before'), LIST])
90
+ turnListInto(e, posOf(e, 'l1'), 'text')
91
+ expect(texts(e)).toEqual(['paragraph p0 before', 'paragraph i1 milk', 'paragraph i2 eggs', 'paragraph i3 brown'])
92
+ })
93
+
94
+ it('a numbered list into a checklist stays numbered', () => {
95
+ const e = editor([{ type: 'orderedList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }])
96
+ turnListInto(e, 0, 'checklist')
97
+ expect(blocks(e)[0].type).toBe('orderedList')
98
+ expect(items(blocks(e)[0])).toEqual(['i1 a false'])
99
+ })
100
+
101
+ it('the kind a list or a quote already is makes no edit', () => {
102
+ // A paragraph last, or StarterKit adds one on the first change and that is an edit.
103
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }, quote('q1', 'b', 'note'), para('p9', 'end')])
104
+ let updates = 0
105
+ e.on('update', () => updates++)
106
+ turnListInto(e, 0, 'bulletList')
107
+ turnQuoteInto(e, posOf(e, 'q1'), 'note')
108
+ expect(updates).toBe(0)
109
+ expect(e.can().undo()).toBe(false)
110
+ })
111
+
112
+ it('does nothing where there is no list', () => {
113
+ const e = editor([para('p0', 'before')])
114
+ expect(turnListInto(e, 0, 'checklist')).toBe(false)
115
+ expect(texts(e)).toEqual(['paragraph p0 before'])
116
+ })
117
+ })
118
+
119
+ describe('turnQuoteInto', () => {
120
+ it('a quote into a Warning and back keeps its id and text', () => {
121
+ const e = editor([quote('q1', 'careful')])
122
+ turnQuoteInto(e, 0, 'warning')
123
+ expect(blocks(e)[0].attrs).toMatchObject({ blockId: 'q1', tone: 'warning' })
124
+ turnQuoteInto(e, 0, 'quote')
125
+ expect(blocks(e)[0].attrs).toMatchObject({ blockId: 'q1', tone: null })
126
+ expect(texts(e)).toEqual(['blockquote q1 careful'])
127
+ })
128
+
129
+ it('a callout into text is a paragraph with the quote’s id', () => {
130
+ const e = editor([quote('q1', 'careful', 'note')])
131
+ turnQuoteInto(e, 0, 'text')
132
+ expect(texts(e)).toEqual(['paragraph q1 careful'])
133
+ })
134
+ })
135
+
136
+ describe('turnIntoRows', () => {
137
+ const labels = (e: Editor, id: string) => turnIntoRows(e.state.doc.nodeAt(posOf(e, id))!, e).map((r) => r.label)
138
+ const selected = (e: Editor, id: string) => {
139
+ const node = e.state.doc.nodeAt(posOf(e, id))!
140
+ return turnIntoRows(node, e).filter((r) => r.active?.(node)).map((r) => r.label)
141
+ }
142
+
143
+ it('offers a list its kinds and Text, a quote its callouts and Text, a paragraph everything', () => {
144
+ const e = editor([para('p1', 'one'), { type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }, quote('q1', 'b', 'warning')])
145
+ expect(labels(e, 'l1')).toEqual(['Bulleted list', 'Numbered list', 'Checklist', 'Text'])
146
+ expect(labels(e, 'q1')).toEqual(['Quote', 'Note', 'Warning', 'Text'])
147
+ expect(labels(e, 'p1')).toEqual(['Text', 'Heading', 'Subheading', 'Quote', 'Code', 'Bulleted list', 'Numbered list', 'Checklist', 'Note', 'Warning'])
148
+ expect(selected(e, 'l1')).toEqual(['Bulleted list'])
149
+ expect(selected(e, 'q1')).toEqual(['Warning'])
150
+ })
151
+
152
+ it('selects Checklist, not Bulleted list, on a list of checklist items', () => {
153
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a', { checked: false })] }])
154
+ expect(selected(e, 'l1')).toEqual(['Checklist'])
155
+ })
156
+
157
+ it('selects no row on a list only partly of checklist items, since every row changes it', () => {
158
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a', { checked: true }), item('i2', 'b')] }])
159
+ expect(selected(e, 'l1')).toEqual([])
160
+ })
161
+
162
+ it('offers no checklist or callout where the editor cannot hold one', () => {
163
+ const e = editor([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'a')] }, quote('q1', 'b')], [StarterKit, BlockId])
164
+ expect(labels(e, 'l1')).toEqual(['Bulleted list', 'Numbered list', 'Text'])
165
+ expect(labels(e, 'q1')).toEqual(['Quote', 'Text'])
166
+ })
167
+
168
+ it('offers a block that is not text, a list or a quote nothing — a rule', () => {
169
+ const e = editor([para('p1', 'one')])
170
+ expect(turnIntoRows(e.schema.nodes.horizontalRule.create(), e)).toEqual([])
171
+ })
172
+ })
173
+
174
+ describe('EditorBlockHandle Turn into', () => {
175
+ async function press(user: ReturnType<typeof userEvent.setup>, target: HTMLElement) {
176
+ await user.pointer({ keys: '[MouseLeft>]', target })
177
+ await user.pointer({ keys: '[/MouseLeft]', target })
178
+ await screen.findByRole('menu')
179
+ }
180
+
181
+ it('turns a list into a checklist from the block menu, with no rows passed', async () => {
182
+ // jsdom lays nothing out: every block's top is 0, so the pointer is on the last — the list.
183
+ const e = editor([para('p1', 'one'), { type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('i1', 'milk'), item('i2', 'eggs')] }])
184
+ const { EditorContent } = await import('@tiptap/react')
185
+ render(
186
+ <EditorBlockHandle editor={e}>
187
+ <EditorContent editor={e} />
188
+ </EditorBlockHandle>,
189
+ )
190
+ const user = userEvent.setup()
191
+ await act(async () => user.hover(screen.getByText('eggs')))
192
+ await press(user, screen.getByRole('button', { name: 'Block menu' }))
193
+ await user.click(screen.getByRole('menuitem', { name: 'Turn into' }))
194
+ await user.click(await screen.findByRole('menuitem', { name: 'Checklist' }))
195
+ await act(() => new Promise<void>((done) => requestAnimationFrame(() => done())))
196
+ const list = blocks(e)[1]
197
+ expect(list.attrs?.blockId).toBe('l1')
198
+ expect(items(list)).toEqual(['i1 milk false', 'i2 eggs false'])
199
+ })
200
+ })