@estiva-app/ui 0.66.0 → 0.67.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.66.0",
3
+ "version": "0.67.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",
@@ -136,6 +136,7 @@
136
136
  "@tiptap/core": "^3.31.3",
137
137
  "@tiptap/extension-code-block-lowlight": "^3.31.3",
138
138
  "@tiptap/extension-mention": "^3.31.3",
139
+ "@tiptap/extension-table": "^3.31.3",
139
140
  "@tiptap/pm": "^3.31.3",
140
141
  "@tiptap/react": "^3.31.3",
141
142
  "@tiptap/starter-kit": "^3.31.3",
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.66.0",
8
- "exports": 144,
9
- "typeExports": 145,
7
+ "packageVersion": "0.67.0",
8
+ "exports": 149,
9
+ "typeExports": 146,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -15,6 +15,44 @@
15
15
  "devUrl": "http://localhost:6008"
16
16
  },
17
17
  "entries": [
18
+ {
19
+ "name": "appendTableColumn",
20
+ "repo": "estiva-ui",
21
+ "kind": "helper",
22
+ "importPath": "@estiva-app/ui/editor",
23
+ "sourceFile": "src/EditorTableControls.tsx",
24
+ "purpose": "A column after the last one: the ➕ right of the table.",
25
+ "purposeFrom": "comment",
26
+ "props": [],
27
+ "variants": [],
28
+ "ownsBehaviours": [],
29
+ "status": "stable",
30
+ "migrationStage": null,
31
+ "docsId": "primitives-editortablecontrols--docs",
32
+ "storyId": "primitives-editortablecontrols--default",
33
+ "docPage": null,
34
+ "app": null,
35
+ "looks": []
36
+ },
37
+ {
38
+ "name": "appendTableRow",
39
+ "repo": "estiva-ui",
40
+ "kind": "helper",
41
+ "importPath": "@estiva-app/ui/editor",
42
+ "sourceFile": "src/EditorTableControls.tsx",
43
+ "purpose": "A row after the last one: the ➕ under the table.",
44
+ "purposeFrom": "comment",
45
+ "props": [],
46
+ "variants": [],
47
+ "ownsBehaviours": [],
48
+ "status": "stable",
49
+ "migrationStage": null,
50
+ "docsId": "primitives-editortablecontrols--docs",
51
+ "storyId": "primitives-editortablecontrols--default",
52
+ "docPage": null,
53
+ "app": null,
54
+ "looks": []
55
+ },
18
56
  {
19
57
  "name": "applyFormat",
20
58
  "repo": "estiva-ui",
@@ -1963,6 +2001,44 @@
1963
2001
  "app": null,
1964
2002
  "looks": []
1965
2003
  },
2004
+ {
2005
+ "name": "deleteTableColumn",
2006
+ "repo": "estiva-ui",
2007
+ "kind": "helper",
2008
+ "importPath": "@estiva-app/ui/editor",
2009
+ "sourceFile": "src/EditorTableControls.tsx",
2010
+ "purpose": "The same for a column: the last one deletes the table.",
2011
+ "purposeFrom": "comment",
2012
+ "props": [],
2013
+ "variants": [],
2014
+ "ownsBehaviours": [],
2015
+ "status": "stable",
2016
+ "migrationStage": null,
2017
+ "docsId": "primitives-editortablecontrols--docs",
2018
+ "storyId": "primitives-editortablecontrols--default",
2019
+ "docPage": null,
2020
+ "app": null,
2021
+ "looks": []
2022
+ },
2023
+ {
2024
+ "name": "deleteTableRow",
2025
+ "repo": "estiva-ui",
2026
+ "kind": "helper",
2027
+ "importPath": "@estiva-app/ui/editor",
2028
+ "sourceFile": "src/EditorTableControls.tsx",
2029
+ "purpose": "The cell's row, or the whole table when it is its only one: a table holds at least one row (prosemirror-tables refuses to delete it).",
2030
+ "purposeFrom": "comment",
2031
+ "props": [],
2032
+ "variants": [],
2033
+ "ownsBehaviours": [],
2034
+ "status": "stable",
2035
+ "migrationStage": null,
2036
+ "docsId": "primitives-editortablecontrols--docs",
2037
+ "storyId": "primitives-editortablecontrols--default",
2038
+ "docPage": null,
2039
+ "app": null,
2040
+ "looks": []
2041
+ },
1966
2042
  {
1967
2043
  "name": "DialogShell",
1968
2044
  "repo": "estiva-ui",
@@ -2292,6 +2368,47 @@
2292
2368
  "app": null,
2293
2369
  "looks": []
2294
2370
  },
2371
+ {
2372
+ "name": "EditorTableControls",
2373
+ "repo": "estiva-ui",
2374
+ "kind": "component",
2375
+ "importPath": "@estiva-app/ui/editor",
2376
+ "sourceFile": "src/EditorTableControls.tsx",
2377
+ "purpose": "A table's own controls in a Tiptap editor: point at a cell for a handle on its row and on its column, and a ➕ bar under the table and right of it.",
2378
+ "purposeFrom": "page",
2379
+ "props": [
2380
+ {
2381
+ "name": "editor",
2382
+ "takes": "Editor | null",
2383
+ "required": true,
2384
+ "note": "The editor, with TableKit (`@tiptap/extension-table`) in its extensions."
2385
+ },
2386
+ {
2387
+ "name": "children",
2388
+ "takes": "anything",
2389
+ "required": true,
2390
+ "note": "The `EditorContent`."
2391
+ },
2392
+ {
2393
+ "name": "className",
2394
+ "takes": "text",
2395
+ "required": false,
2396
+ "note": null
2397
+ }
2398
+ ],
2399
+ "variants": [],
2400
+ "ownsBehaviours": [],
2401
+ "status": "stable",
2402
+ "migrationStage": null,
2403
+ "docsId": "primitives-editortablecontrols--docs",
2404
+ "storyId": "primitives-editortablecontrols--default",
2405
+ "docPage": "src/EditorTableControls.mdx",
2406
+ "app": null,
2407
+ "looks": [
2408
+ "rounded-lg border border-border-default bg-bg-elevated shadow-lg shadow-none -translate-x-1/2 -translate-y-1/2",
2409
+ "rounded-lg border border-border-default bg-bg-elevated shadow-lg shadow-none"
2410
+ ]
2411
+ },
2295
2412
  {
2296
2413
  "name": "EmptyState",
2297
2414
  "repo": "estiva-ui",
@@ -4240,6 +4357,12 @@
4240
4357
  "required": false,
4241
4358
  "note": "The ⋮'s accessible name and tooltip. Default \"More options for {label}\"."
4242
4359
  },
4360
+ {
4361
+ "name": "action",
4362
+ "takes": "SectionAction",
4363
+ "required": false,
4364
+ "note": "One button beside the ⋮, shown with it: any 16px icon, its tooltip and its handler — a `SectionHeader` action's shape."
4365
+ },
4243
4366
  {
4244
4367
  "name": "hint",
4245
4368
  "takes": "anything",
@@ -5512,7 +5635,7 @@
5512
5635
  "[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:border [&_:not(pre)>code]:border-border-default [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-text-primary",
5513
5636
  "[&_pre>code]:text-[1em] [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:rounded-md [&_pre]:bg-bg-code [&_pre]:px-3 [&_pre]:py-2.5 [&_pre]:font-mono [&_pre]:text-[0.85em] [&_pre]:leading-relaxed [&_pre]:text-text-primary",
5514
5637
  "[&_:is(.hljs-keyword,.hljs-doctag,.hljs-type,.hljs-template-tag,.hljs-template-variable)]:text-syntax-keyword [&_.hljs-title]:text-syntax-function [&_:is(.hljs-number,.hljs-literal,.hljs-attr,.hljs-attribute,.hljs-meta,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id)]:text-syntax-constant [&_:is(.hljs-string,.hljs-regexp)]:text-syntax-string [&_:is(.hljs-built_in,.hljs-symbol)]:text-syntax-variable [&_.hljs-comment]:text-syntax-comment [&_:is(.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo)]:text-syntax-tag",
5515
- "[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary",
5638
+ "[&_:is(th,td)>p]:m-0 [&_:is(th,td)]:border [&_:is(th,td)]:border-border-default [&_:is(th,td)]:px-2 [&_:is(th,td)]:py-1 [&_:is(th,td)]:text-left [&_:is(th,td)]:align-top [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:bg-bg-code [&_th]:font-medium [&_th]:text-text-primary",
5516
5639
  "[&_em]:italic [&_strong]:font-semibold [&_strong]:text-text-primary [&_u]:underline [&_u]:decoration-text-muted [&_u]:underline-offset-2",
5517
5640
  "text-body-2 leading-normal [&>:is(h2,h3)]:text-body-2 [&>:is(h2,h3)]:font-semibold [&>h1]:text-h4",
5518
5641
  "text-caption leading-snug [&>:is(h1,h2,h3)]:mb-0.5 [&>:is(h1,h2,h3)]:mt-1.5 [&>:is(h1,h2,h3)+:is(h1,h2,h3)]:mt-1 [&>:is(h1,h2,h3):last-child]:mb-0 [&>:is(h2,h3)]:text-caption [&>:is(h2,h3)]:font-semibold [&>h1]:text-body-2 [&>h1]:font-semibold"
@@ -0,0 +1,78 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as EditorTableControlsStories from './EditorTableControls.stories'
3
+
4
+ <Meta of={EditorTableControlsStories} />
5
+
6
+ # EditorTableControls
7
+
8
+ A table's own controls in a Tiptap editor: point at a cell for a handle on its
9
+ row and on its column, and a ➕ bar under the table and right of it.
10
+
11
+ <Canvas of={EditorTableControlsStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **An editor whose documents hold tables** — with TableKit
16
+ (`@tiptap/extension-table`) in its extensions. Point at a cell: the row's
17
+ handle sits on the table's left edge, the column's on its top edge; each
18
+ opens Insert (above and below, or left and right), Delete, and Delete
19
+ table. A header row offers nothing above it, and deleting it makes the next
20
+ row the header, so a table always opens with its header. From the last row, a ➕ under the table adds a row after it; from the
21
+ last column, a ➕ right of the table adds a column after it.
22
+ - **Not editable**: nothing is drawn.
23
+
24
+ ## When not
25
+
26
+ - Moving, duplicating or deleting the table as one block → **EditorBlockHandle**,
27
+ whose handle stays the table's, in the gutter. Wrap this inside it.
28
+ - A table being read, with no editor → **RichText** draws it; there is nothing
29
+ to change.
30
+
31
+ ## How
32
+
33
+ ```tsx
34
+ import { EditorContent, useEditor } from '@tiptap/react'
35
+ import StarterKit from '@tiptap/starter-kit'
36
+ import { TableKit } from '@tiptap/extension-table'
37
+ import { BlockId, EditorBlockHandle, EditorTableControls } from '@estiva-app/ui/editor'
38
+
39
+ const editor = useEditor({ extensions: [StarterKit, TableKit.configure({ table: { resizable: false } }), BlockId] })
40
+
41
+ <EditorBlockHandle editor={editor}>
42
+ <EditorTableControls editor={editor}>
43
+ <EditorContent editor={editor} />
44
+ </EditorTableControls>
45
+ </EditorBlockHandle>
46
+ ```
47
+
48
+ - **Every control leaves the editor focused**, in the cell it acted on, as the
49
+ block menu does. An editor that saves when the person leaves it saves these
50
+ the same way.
51
+ - A table cannot be left with no row or no column: **deleting its only row, or
52
+ its only column, deletes the table**.
53
+ - The column ➕ hangs 16px right of the table; give the editor that room, as
54
+ the block handle has its 28px on the left. The row ➕ lies 16px over the top
55
+ of the block under the table, only while the pointer is on the last row.
56
+ - `appendTableRow`, `appendTableColumn`, `deleteTableRow` and
57
+ `deleteTableColumn` are the controls' own actions, for a caller that offers
58
+ them elsewhere (a shortcut, a test).
59
+
60
+ ## What it owns
61
+
62
+ It does these by itself. Write none of them around it.
63
+
64
+ | It does | So you never write | Refused when written by hand |
65
+ |---|---|---|
66
+ | Runs TableKit's own commands with the caret in the cell pointed at | a selection before `addRowAfter` | |
67
+ | Gives new rows and cells fresh ids, the old ones keeping theirs (`BlockId` decides) | an id for a new cell | |
68
+ | Turns a delete that would leave the table empty into a delete of the table | a check of the table's size | |
69
+
70
+ ## Keys
71
+
72
+ | Input | What happens |
73
+ |---|---|
74
+ | Tab, in the last cell | adds a row and moves into it — TableKit's own |
75
+ | Tab / Shift+Tab, in a cell | to the next / previous cell — TableKit's own |
76
+
77
+ The handles and the ➕ bars are for the pointer: there is no hover on a touch
78
+ screen, and nothing here draws them there yet.
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { EditorContent, useEditor } from '@tiptap/react'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import { TableKit } from '@tiptap/extension-table'
5
+ import { BlockId } from './BlockNodes'
6
+ import { EditorBlockHandle, blockDropCursor } from './EditorBlockHandle'
7
+ import { EditorTableControls } from './EditorTableControls'
8
+ import { richTextClassName } from './RichText'
9
+
10
+ /**
11
+ * A table's controls in an editor. Point at a cell: a handle on its row's left
12
+ * edge and on its column's top edge, and a ➕ bar under the table and right of
13
+ * it.
14
+ */
15
+ const meta = {
16
+ title: 'Primitives/EditorTableControls',
17
+ // The block handle sits 28px left of the content, and the column ➕ 16px right of the table.
18
+ parameters: { layout: 'padded' },
19
+ } satisfies Meta
20
+
21
+ export default meta
22
+ type Story = StoryObj
23
+
24
+ const text = (t: string) => [{ type: 'paragraph', content: [{ type: 'text', text: t }] }]
25
+ const row = (type: 'tableHeader' | 'tableCell', ...cells: string[]) => ({ type: 'tableRow', content: cells.map((c) => ({ type, content: text(c) })) })
26
+
27
+ const DOCUMENT = {
28
+ type: 'doc',
29
+ content: [
30
+ { type: 'paragraph', content: [{ type: 'text', text: 'Point at a cell for its row and column handles.' }] },
31
+ {
32
+ type: 'table',
33
+ content: [row('tableHeader', 'Label', 'Label', 'Label'), row('tableCell', 'Item one', 'Value', 'Value'), row('tableCell', 'Item two', 'Value', 'Value')],
34
+ },
35
+ { type: 'paragraph', content: [{ type: 'text', text: 'The bar under the table adds a row; the one right of it, a column.' }] },
36
+ ],
37
+ }
38
+
39
+ function Editing({ editable }: { editable: boolean }) {
40
+ const editor = useEditor({
41
+ extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), TableKit.configure({ table: { resizable: false } }), BlockId],
42
+ content: DOCUMENT,
43
+ editable,
44
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
45
+ })
46
+ return (
47
+ <EditorBlockHandle editor={editor} className="ml-8 max-w-[560px]">
48
+ <EditorTableControls editor={editor}>
49
+ <EditorContent editor={editor} />
50
+ </EditorTableControls>
51
+ </EditorBlockHandle>
52
+ )
53
+ }
54
+
55
+ /** Point at a cell; press a handle for Insert and Delete, or a ➕ bar to add a row or a column at the end. */
56
+ export const Default: Story = {
57
+ render: () => <Editing editable />,
58
+ }
59
+
60
+ /** An editor that is not editable: no controls. */
61
+ export const NotEditable: Story = {
62
+ render: () => <Editing editable={false} />,
63
+ }
@@ -0,0 +1,288 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A table's controls in an editor (RIC-21): what each row of the row and column
4
+ * menus and each ➕ bar does to the document, that new rows and cells get ids of
5
+ * their own, and that deleting a table's only row or column deletes the table.
6
+ * Where the controls sit is measured in real Chromium (the PR says how): jsdom
7
+ * lays nothing out.
8
+ */
9
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest'
10
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
11
+ import userEvent from '@testing-library/user-event'
12
+ import { Editor } from '@tiptap/core'
13
+ import StarterKit from '@tiptap/starter-kit'
14
+ import { TableKit } from '@tiptap/extension-table'
15
+ import { BlockId } from './BlockNodes'
16
+ import { EditorTableControls, appendTableColumn, appendTableRow, deleteTableColumn, deleteTableRow } from './EditorTableControls'
17
+
18
+ type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[]; text?: string }
19
+
20
+ const cell = (type: 'tableHeader' | 'tableCell', text: string): Json => ({ type, content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] })
21
+ const row = (type: 'tableHeader' | 'tableCell', ...texts: string[]): Json => ({ type: 'tableRow', content: texts.map((t) => cell(type, t)) })
22
+ const table = (...rows: Json[]): object => ({ type: 'doc', content: [{ type: 'table', attrs: { blockId: 't1' }, content: rows }, { type: 'paragraph', content: [{ type: 'text', text: 'after' }] }] })
23
+ const THREE = table(row('tableHeader', 'A', 'B', 'C'), row('tableCell', 'a1', 'b1', 'c1'), row('tableCell', 'a2', 'b2', 'c2'))
24
+
25
+ const editors: Editor[] = []
26
+ function editor(content: object = THREE, editable = true) {
27
+ const e = new Editor({ extensions: [StarterKit, TableKit.configure({ table: { resizable: false } }), BlockId], content, editable })
28
+ editors.push(e)
29
+ return e
30
+ }
31
+
32
+ /** The table as text, a row a line: `#` before a header cell. */
33
+ function shape(e: Editor): string[] {
34
+ const t = (e.getJSON().content as Json[]).find((b) => b.type === 'table')
35
+ if (!t) return []
36
+ const text = (n: Json): string => n.text ?? (n.content ?? []).map(text).join('')
37
+ return (t.content ?? []).map((r) => (r.content ?? []).map((c) => (c.type === 'tableHeader' ? '#' : '') + text(c)).join('|'))
38
+ }
39
+ const cellOf = (text: string) => screen.getByText(text).closest('td, th') as HTMLTableCellElement
40
+ const tableOf = (e: Editor) => e.view.dom.querySelector('table') as HTMLTableElement
41
+
42
+ /** Every id under the table: rows and cells, each its own. */
43
+ function ids(e: Editor): unknown[] {
44
+ const out: unknown[] = []
45
+ e.state.doc.descendants((n) => {
46
+ if (n.type.name.startsWith('table')) out.push(n.attrs.blockId)
47
+ })
48
+ return out
49
+ }
50
+
51
+ /** Press and release, as BlockHandle's test does: under jsdom `user.click` left a Base UI menu shut. */
52
+ async function press(user: ReturnType<typeof userEvent.setup>, target: HTMLElement) {
53
+ await user.pointer({ keys: '[MouseLeft>]', target })
54
+ await user.pointer({ keys: '[/MouseLeft]', target })
55
+ await screen.findByRole('menu')
56
+ }
57
+ /**
58
+ * A click on a ➕ bar, with no pointer move before it. user-event moves the
59
+ * pointer first, and jsdom, with no layout, reports that move as leaving the
60
+ * frame (a `mouseleave` with no `relatedTarget`), which takes the bar away
61
+ * before its click. In Chromium the pointer reaches the bar from the cell (the
62
+ * PR says how).
63
+ */
64
+ const tap = (target: HTMLElement) => act(async () => void fireEvent.click(target))
65
+ /** The editor takes the focus back a frame later and measures the caret then. */
66
+ const frame = () => act(() => new Promise<void>((done) => requestAnimationFrame(() => done())))
67
+ /**
68
+ * `BlockId` gives the blocks their ids on the editor's `create`, a tick after it
69
+ * is made — which redraws every cell, so a cell found before it is no longer the
70
+ * editor's. Wait for it.
71
+ */
72
+ const created = () => act(() => new Promise<void>((done) => setTimeout(done, 0)))
73
+
74
+ beforeAll(() => {
75
+ const none = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }
76
+ Range.prototype.getClientRects = () => [] as unknown as DOMRectList
77
+ Range.prototype.getBoundingClientRect = () => none as DOMRect
78
+ })
79
+
80
+ afterEach(() => {
81
+ cleanup()
82
+ for (const e of editors.splice(0)) e.destroy()
83
+ })
84
+
85
+ async function mount(e: Editor) {
86
+ const { EditorContent } = await import('@tiptap/react')
87
+ render(
88
+ <EditorTableControls editor={e}>
89
+ <EditorContent editor={e} />
90
+ </EditorTableControls>,
91
+ )
92
+ await created()
93
+ return userEvent.setup()
94
+ }
95
+
96
+ describe('the actions', () => {
97
+ it('a ➕ under the table adds an empty row after the last, of cells', () => {
98
+ const e = editor()
99
+ expect(appendTableRow(e, tableOf(e))).toBe(true)
100
+ expect(shape(e)).toEqual(['#A|#B|#C', 'a1|b1|c1', 'a2|b2|c2', '||'])
101
+ })
102
+
103
+ it('a ➕ right of the table adds an empty column after the last', () => {
104
+ const e = editor()
105
+ expect(appendTableColumn(e, tableOf(e))).toBe(true)
106
+ expect(shape(e)).toEqual(['#A|#B|#C|#', 'a1|b1|c1|', 'a2|b2|c2|'])
107
+ })
108
+
109
+ it('new rows and cells get ids of their own; the old ones keep theirs', async () => {
110
+ const e = editor()
111
+ await created()
112
+ const before = ids(e)
113
+ expect(before.every((id) => typeof id === 'string')).toBe(true)
114
+ appendTableRow(e, tableOf(e))
115
+ appendTableColumn(e, tableOf(e))
116
+ const after = ids(e)
117
+ expect(after.every((id) => typeof id === 'string' && id.length > 0)).toBe(true)
118
+ expect(new Set(after).size).toBe(after.length)
119
+ expect(before.every((id) => after.includes(id))).toBe(true)
120
+ })
121
+
122
+ it('deleting a row or a column leaves the rest as they were', async () => {
123
+ const e = editor()
124
+ await mount(e)
125
+ expect(deleteTableRow(e, cellOf('a1'))).toBe(true)
126
+ expect(deleteTableColumn(e, cellOf('B'))).toBe(true)
127
+ expect(shape(e)).toEqual(['#A|#C', 'a2|c2'])
128
+ })
129
+
130
+ it('Tab in the last cell adds a row; in another, moves to the next cell (TableKit’s own)', () => {
131
+ const e = editor()
132
+ const textAt = (text: string) => {
133
+ let at = -1
134
+ e.state.doc.descendants((n, pos) => {
135
+ if (n.isText && n.text === text) at = pos
136
+ })
137
+ return at
138
+ }
139
+ // The caret's row in the table, and its cell in the row.
140
+ const at = () => [e.state.selection.$from.index(1), e.state.selection.$from.index(2)]
141
+ // The key as the view gets it: `commands.keyboardShortcut` runs inside a command, whose own transaction then puts the selection back.
142
+ const press = (key: string, shiftKey = false) => e.view.someProp('handleKeyDown', (f) => f(e.view, new KeyboardEvent('keydown', { key, shiftKey })))
143
+ e.commands.setTextSelection(textAt('a1'))
144
+ press('Tab')
145
+ expect(at()).toEqual([1, 1])
146
+ press('Tab', true)
147
+ expect(at()).toEqual([1, 0])
148
+ e.commands.setTextSelection(textAt('c2'))
149
+ press('Tab')
150
+ expect(shape(e)).toEqual(['#A|#B|#C', 'a1|b1|c1', 'a2|b2|c2', '||'])
151
+ expect(at()).toEqual([3, 0])
152
+ })
153
+
154
+ it('deleting the only row, or the only column, deletes the table', async () => {
155
+ const one = editor(table(row('tableCell', 'x', 'y')))
156
+ await mount(one)
157
+ expect(deleteTableRow(one, cellOf('x'))).toBe(true)
158
+ expect(shape(one)).toEqual([])
159
+ cleanup()
160
+ const narrow = editor(table(row('tableCell', 'p'), row('tableCell', 'q')))
161
+ await mount(narrow)
162
+ expect(deleteTableColumn(narrow, cellOf('q'))).toBe(true)
163
+ expect(shape(narrow)).toEqual([])
164
+ expect(narrow.getText()).toContain('after')
165
+ })
166
+ })
167
+
168
+ describe('EditorTableControls', () => {
169
+ it('draws nothing until the pointer is on a cell', async () => {
170
+ const user = await mount(editor())
171
+ expect(screen.queryByRole('button', { name: 'Row menu' })).toBeNull()
172
+ await act(async () => user.hover(screen.getByText('after')))
173
+ expect(screen.queryByRole('button', { name: 'Row menu' })).toBeNull()
174
+ await act(async () => user.hover(screen.getByText('b1')))
175
+ expect(screen.getByRole('button', { name: 'Row menu' })).toBeTruthy()
176
+ expect(screen.getByRole('button', { name: 'Column menu' })).toBeTruthy()
177
+ expect(screen.queryByRole('button', { name: 'Add row' })).toBeNull()
178
+ expect(screen.queryByRole('button', { name: 'Add column' })).toBeNull()
179
+ })
180
+
181
+ it('offers the ➕ under the table from the last row, and the one right of it from the last column', async () => {
182
+ const user = await mount(editor())
183
+ await act(async () => user.hover(screen.getByText('b2')))
184
+ expect(screen.getByRole('button', { name: 'Add row' })).toBeTruthy()
185
+ expect(screen.queryByRole('button', { name: 'Add column' })).toBeNull()
186
+ await act(async () => user.hover(screen.getByText('c1')))
187
+ expect(screen.queryByRole('button', { name: 'Add row' })).toBeNull()
188
+ expect(screen.getByRole('button', { name: 'Add column' })).toBeTruthy()
189
+ await act(async () => user.hover(screen.getByText('c2')))
190
+ expect(screen.getByRole('button', { name: 'Add row' })).toBeTruthy()
191
+ expect(screen.getByRole('button', { name: 'Add column' })).toBeTruthy()
192
+ })
193
+
194
+ it('the row menu inserts above and below the row pointed at, and deletes it', async () => {
195
+ const e = editor()
196
+ const user = await mount(e)
197
+ await act(async () => user.hover(screen.getByText('b1')))
198
+ await press(user, screen.getByRole('button', { name: 'Row menu' }))
199
+ expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Insert above', 'Insert below', 'Delete', 'Delete table'])
200
+ await user.click(screen.getByRole('menuitem', { name: 'Insert below' }))
201
+ await frame()
202
+ expect(shape(e)).toEqual(['#A|#B|#C', 'a1|b1|c1', '||', 'a2|b2|c2'])
203
+
204
+ await act(async () => user.hover(screen.getByText('a2')))
205
+ await press(user, screen.getByRole('button', { name: 'Row menu' }))
206
+ await user.click(screen.getByRole('menuitem', { name: 'Delete' }))
207
+ await frame()
208
+ expect(shape(e)).toEqual(['#A|#B|#C', 'a1|b1|c1', '||'])
209
+ })
210
+
211
+ it('the column menu inserts left and right of the column pointed at, and deletes it', async () => {
212
+ const e = editor()
213
+ const user = await mount(e)
214
+ await act(async () => user.hover(screen.getByText('b2')))
215
+ await press(user, screen.getByRole('button', { name: 'Column menu' }))
216
+ expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Insert left', 'Insert right', 'Delete', 'Delete table'])
217
+ await user.click(screen.getByRole('menuitem', { name: 'Insert left' }))
218
+ await frame()
219
+ expect(shape(e)).toEqual(['#A|#|#B|#C', 'a1||b1|c1', 'a2||b2|c2'])
220
+
221
+ await act(async () => user.hover(screen.getByText('c1')))
222
+ await press(user, screen.getByRole('button', { name: 'Column menu' }))
223
+ await user.click(screen.getByRole('menuitem', { name: 'Delete' }))
224
+ await frame()
225
+ expect(shape(e)).toEqual(['#A|#|#B', 'a1||b1', 'a2||b2'])
226
+ })
227
+
228
+ it('a header row offers nothing above it, and deleting it makes the next row the header', async () => {
229
+ const e = editor()
230
+ const user = await mount(e)
231
+ await act(async () => user.hover(screen.getByText('B')))
232
+ await press(user, screen.getByRole('button', { name: 'Row menu' }))
233
+ expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Insert below', 'Delete', 'Delete table'])
234
+ await user.click(screen.getByRole('menuitem', { name: 'Delete' }))
235
+ await frame()
236
+ expect(shape(e)).toEqual(['#a1|#b1|#c1', 'a2|b2|c2'])
237
+ })
238
+
239
+ it('takes the controls away when the document changes without an update — someone else’s edit loaded', async () => {
240
+ const e = editor()
241
+ const user = await mount(e)
242
+ await act(async () => user.hover(screen.getByText('b1')))
243
+ expect(screen.getByRole('button', { name: 'Row menu' })).toBeTruthy()
244
+ await act(async () => void e.commands.setContent(THREE, { emitUpdate: false }))
245
+ expect(screen.queryByRole('button', { name: 'Row menu' })).toBeNull()
246
+ })
247
+
248
+ it('Delete table removes the table and keeps the text after it', async () => {
249
+ const e = editor()
250
+ const user = await mount(e)
251
+ await act(async () => user.hover(screen.getByText('a1')))
252
+ await press(user, screen.getByRole('button', { name: 'Row menu' }))
253
+ await user.click(screen.getByRole('menuitem', { name: 'Delete table' }))
254
+ await frame()
255
+ expect(shape(e)).toEqual([])
256
+ expect(e.getText()).toContain('after')
257
+ })
258
+
259
+ it('the ➕ bars add a row and a column at the end', async () => {
260
+ const e = editor()
261
+ const user = await mount(e)
262
+ await act(async () => user.hover(screen.getByText('a2')))
263
+ // The press keeps the focus in the field, so a field that saves on leaving does not save half-way: `false` is a prevented default.
264
+ expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Add row' }))).toBe(false)
265
+ await tap(screen.getByRole('button', { name: 'Add row' }))
266
+ await frame()
267
+ await act(async () => user.hover(screen.getByText('c1')))
268
+ await tap(screen.getByRole('button', { name: 'Add column' }))
269
+ await frame()
270
+ expect(shape(e)).toEqual(['#A|#B|#C|#', 'a1|b1|c1|', 'a2|b2|c2|', '|||'])
271
+ })
272
+
273
+ it('an editor that cannot be edited gets no controls', async () => {
274
+ const user = await mount(editor(THREE, false))
275
+ await act(async () => user.hover(screen.getByText('b1')))
276
+ expect(screen.queryByRole('button', { name: 'Row menu' })).toBeNull()
277
+ expect(screen.queryByRole('button', { name: 'Add row' })).toBeNull()
278
+ })
279
+
280
+ it('draws nothing before its editor exists', () => {
281
+ render(
282
+ <EditorTableControls editor={null}>
283
+ <p>text</p>
284
+ </EditorTableControls>,
285
+ )
286
+ expect(screen.queryByRole('button')).toBeNull()
287
+ })
288
+ })