@estiva-app/ui 0.65.3 → 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/dist/BlockNodes.d.ts +33 -0
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +11 -2
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/EditorTableControls.d.ts +38 -0
- package/dist/EditorTableControls.d.ts.map +1 -0
- package/dist/NavItem.d.ts +11 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/RichText.d.ts +13 -1
- package/dist/RichText.d.ts.map +1 -1
- package/dist/editor.d.ts +2 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +363 -18
- package/dist/editor.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +71 -23
- package/dist/index.js.map +3 -3
- package/package.json +2 -1
- package/registry.json +209 -4
- package/src/BlockNodes.mdx +32 -1
- package/src/BlockNodes.stories.tsx +29 -1
- package/src/BlockNodes.test.tsx +141 -2
- package/src/BlockNodes.tsx +227 -4
- package/src/ComposerTriggers.test.tsx +23 -0
- package/src/ComposerTriggers.tsx +61 -5
- package/src/EditorTableControls.mdx +78 -0
- package/src/EditorTableControls.stories.tsx +63 -0
- package/src/EditorTableControls.test.tsx +288 -0
- package/src/EditorTableControls.tsx +255 -0
- package/src/NavItem.mdx +10 -0
- package/src/NavItem.stories.tsx +23 -1
- package/src/NavItem.test.tsx +36 -0
- package/src/NavItem.tsx +40 -16
- package/src/RichText.mdx +12 -0
- package/src/RichText.stories.tsx +50 -0
- package/src/RichText.test.tsx +56 -0
- package/src/RichText.tsx +83 -10
- package/src/editor.ts +4 -0
- package/src/index.ts +1 -1
- package/src/registry/registry.test.ts +7 -7
- package/src/registry/siblings.test.ts +2 -2
- package/stories/Choosing.mdx +1 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import { TextSelection, type Command } from '@tiptap/pm/state'
|
|
4
|
+
import { addColumnAfter, addColumnBefore, addRowAfter, addRowBefore, deleteColumn, deleteRow, deleteTable, toggleHeader } from '@tiptap/pm/tables'
|
|
5
|
+
import { IconColumnInsertLeft, IconColumnInsertRight, IconGripHorizontal, IconGripVertical, IconPlus, IconRowInsertBottom, IconRowInsertTop, IconTrash } from '@tabler/icons-react'
|
|
6
|
+
import { cn } from './cn'
|
|
7
|
+
import { IconButton } from './IconButton'
|
|
8
|
+
import { FLOATING_SURFACE_CLASSES } from './looks'
|
|
9
|
+
import { Menu, MenuItem, MenuSeparator } from './Menu'
|
|
10
|
+
|
|
11
|
+
export interface EditorTableControlsProps {
|
|
12
|
+
/** The editor, with TableKit (`@tiptap/extension-table`) in its extensions. Not editable, or not there yet: no controls. */
|
|
13
|
+
editor: Editor | null
|
|
14
|
+
/** The `EditorContent`. */
|
|
15
|
+
children: ReactNode
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The cell the pointer is on, and the table and row it is in. */
|
|
20
|
+
interface Hovered {
|
|
21
|
+
table: HTMLTableElement
|
|
22
|
+
row: HTMLTableRowElement
|
|
23
|
+
cell: HTMLTableCellElement
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where the controls go, measured from the frame: they depend on the document, not on a size of ours. */
|
|
27
|
+
interface Placed {
|
|
28
|
+
tableBox: { top: number; left: number; width: number; height: number }
|
|
29
|
+
rowBox: { top: number; height: number }
|
|
30
|
+
columnBox: { left: number; width: number }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
Every row runs the table command with the caret in a cell, as the command
|
|
35
|
+
expects — the hovered cell's, or for a ➕ bar the last row's or the last
|
|
36
|
+
column's — and leaves the editor focused there, as the block menu does: the
|
|
37
|
+
person is still in the field, so an editor that saves when they leave it
|
|
38
|
+
saves this too.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Puts the caret in `cell` and runs `command` there. */
|
|
42
|
+
function inCell(editor: Editor, cell: HTMLTableCellElement, command: Command): boolean {
|
|
43
|
+
const { view } = editor
|
|
44
|
+
if (!view.dom.contains(cell)) return false
|
|
45
|
+
const at = view.posAtDOM(cell, 0)
|
|
46
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(at))))
|
|
47
|
+
const done = command(view.state, view.dispatch, view)
|
|
48
|
+
editor.commands.focus()
|
|
49
|
+
return done
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A row of header cells only: the table's header row. */
|
|
53
|
+
function isHeaderRow(row: HTMLTableRowElement): boolean {
|
|
54
|
+
return row.cells.length > 0 && Array.from(row.cells).every((c) => c.tagName === 'TH')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The cell's row, or the whole table when it is its only one: a table holds at
|
|
59
|
+
* least one row (prosemirror-tables refuses to delete it). Deleting the header
|
|
60
|
+
* row makes the next row the header, so the table still opens with one.
|
|
61
|
+
*/
|
|
62
|
+
export function deleteTableRow(editor: Editor, cell: HTMLTableCellElement): boolean {
|
|
63
|
+
const row = cell.closest('tr')
|
|
64
|
+
const table = cell.closest('table')
|
|
65
|
+
if (!row || !table || table.rows.length <= 1) return inCell(editor, cell, deleteTable)
|
|
66
|
+
const header = isHeaderRow(row)
|
|
67
|
+
return inCell(editor, cell, header ? (state, dispatch, view) => deleteRow(state, dispatch) && (!view || toggleHeader('row')(view.state, view.dispatch)) : deleteRow)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The same for a column: the last one deletes the table. */
|
|
71
|
+
export function deleteTableColumn(editor: Editor, cell: HTMLTableCellElement): boolean {
|
|
72
|
+
const row = cell.closest('tr')
|
|
73
|
+
return inCell(editor, cell, row && row.cells.length > 1 ? deleteColumn : deleteTable)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A row after the last one: the ➕ under the table. */
|
|
77
|
+
export function appendTableRow(editor: Editor, table: HTMLTableElement): boolean {
|
|
78
|
+
const last = table.rows[table.rows.length - 1]?.cells[0]
|
|
79
|
+
return !!last && inCell(editor, last, addRowAfter)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A column after the last one: the ➕ right of the table. */
|
|
83
|
+
export function appendTableColumn(editor: Editor, table: HTMLTableElement): boolean {
|
|
84
|
+
const first = table.rows[0]
|
|
85
|
+
const last = first?.cells[first.cells.length - 1]
|
|
86
|
+
return !!last && inCell(editor, last, addColumnAfter)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function place(frame: HTMLElement, { table, row, cell }: Hovered): Placed {
|
|
90
|
+
const at = frame.getBoundingClientRect()
|
|
91
|
+
const t = table.getBoundingClientRect()
|
|
92
|
+
const r = row.getBoundingClientRect()
|
|
93
|
+
const c = cell.getBoundingClientRect()
|
|
94
|
+
return {
|
|
95
|
+
tableBox: { top: t.top - at.top, left: t.left - at.left, width: t.width, height: t.height },
|
|
96
|
+
rowBox: { top: r.top - at.top, height: r.height },
|
|
97
|
+
columnBox: { left: c.left - at.left, width: c.width },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ICON = { size: 16, stroke: 1.5, className: 'text-text-secondary' } as const
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A ➕ press keeps the focus where it is, as the selection toolbar's buttons do:
|
|
105
|
+
* the person never left the field, so an editor that saves when they leave it
|
|
106
|
+
* does not save half-way, and History gets one line for the edit, not one per
|
|
107
|
+
* press. (A handle's menu takes the focus, as every menu does.)
|
|
108
|
+
*/
|
|
109
|
+
const keepFocus = (e: MouseEvent) => e.preventDefault()
|
|
110
|
+
|
|
111
|
+
/** What a handle and a ➕ bar sit in: the elevated surface's border and fill, opaque over the table's lines, with no shadow at this size. */
|
|
112
|
+
const HANDLE_BOX = cn(FLOATING_SURFACE_CLASSES, 'shadow-none')
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A table's own controls in an editor, as in Notion: point at a cell and a
|
|
116
|
+
* handle sits on its row's left edge and on its column's top edge; press one
|
|
117
|
+
* for Insert above and below (left and right), Delete, and Delete table. On
|
|
118
|
+
* the last row a ➕ bar under the table adds a row, and on the last column one
|
|
119
|
+
* right of it adds a column. Tab in
|
|
120
|
+
* the last cell adds a row too — that is TableKit's own.
|
|
121
|
+
*
|
|
122
|
+
* Deleting a table's only row or only column deletes the table, which cannot
|
|
123
|
+
* be left with none.
|
|
124
|
+
*
|
|
125
|
+
* Built on the editor library's own table commands; the controls are ours.
|
|
126
|
+
* Wrap the `EditorContent` in it, inside an `EditorBlockHandle` if there is
|
|
127
|
+
* one: the block handle stays the table's, in the gutter.
|
|
128
|
+
*/
|
|
129
|
+
export function EditorTableControls({ editor, children, className }: EditorTableControlsProps) {
|
|
130
|
+
const frame = useRef<HTMLDivElement>(null)
|
|
131
|
+
// The cell pointed at and where its controls go, as one: the boxes mean nothing without their elements.
|
|
132
|
+
const [hovered, setHovered] = useState<(Hovered & Placed) | null>(null)
|
|
133
|
+
// A menu that is up keeps its row and column, wherever the pointer goes.
|
|
134
|
+
const [menu, setMenu] = useState<'row' | 'column' | null>(null)
|
|
135
|
+
const editable = !!editor && editor.isEditable
|
|
136
|
+
|
|
137
|
+
const follow = useCallback(
|
|
138
|
+
(target: EventTarget | null) => {
|
|
139
|
+
if (!editor || !frame.current || !(target instanceof Element)) return
|
|
140
|
+
// On a control of ours: keep what it was drawn for.
|
|
141
|
+
if (target.closest('[data-table-control]')) return
|
|
142
|
+
const cell = target.closest<HTMLTableCellElement>('td, th')
|
|
143
|
+
const row = cell?.closest('tr')
|
|
144
|
+
const table = row?.closest('table')
|
|
145
|
+
if (!cell || !row || !table || !editor.view.dom.contains(table)) {
|
|
146
|
+
setHovered(null)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
// Measured on every move, the same cell too: a panel resized or a cell's text wrapping moves the edges under a still pointer.
|
|
150
|
+
const next = { table, row, cell, ...place(frame.current, { table, row, cell }) }
|
|
151
|
+
setHovered((was) => (was && was.cell === cell && JSON.stringify([was.tableBox, was.rowBox, was.columnBox]) === JSON.stringify([next.tableBox, next.rowBox, next.columnBox]) ? was : next))
|
|
152
|
+
},
|
|
153
|
+
[editor],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
Any change to the document moves everything under the controls, so they go
|
|
158
|
+
until the pointer moves again: a row added or deleted here, and someone
|
|
159
|
+
else's edit loaded into the field — which an app does without an `update`
|
|
160
|
+
(`emitUpdate: false`), and which redraws the cells the controls point at.
|
|
161
|
+
*/
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!editor) return
|
|
164
|
+
const changed = ({ transaction }: { transaction: { docChanged: boolean } }) => {
|
|
165
|
+
if (transaction.docChanged && !menu) setHovered(null)
|
|
166
|
+
}
|
|
167
|
+
editor.on('transaction', changed)
|
|
168
|
+
return () => {
|
|
169
|
+
editor.off('transaction', changed)
|
|
170
|
+
}
|
|
171
|
+
}, [editor, menu])
|
|
172
|
+
|
|
173
|
+
const shown = editable ? hovered : null
|
|
174
|
+
const run = (act: () => boolean) => () => {
|
|
175
|
+
if (editor) act()
|
|
176
|
+
setMenu(null)
|
|
177
|
+
setHovered(null)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
ref={frame}
|
|
183
|
+
className={cn('relative', className)}
|
|
184
|
+
onMouseMove={(e) => {
|
|
185
|
+
if (!menu) follow(e.target)
|
|
186
|
+
}}
|
|
187
|
+
onMouseLeave={() => {
|
|
188
|
+
if (!menu) setHovered(null)
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
{children}
|
|
192
|
+
{shown && editor && (
|
|
193
|
+
<>
|
|
194
|
+
{/* The row's handle, centred on the table's left edge: the box is ours, so the table's line does not show through it. */}
|
|
195
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute -translate-x-1/2 -translate-y-1/2')} style={{ left: shown.tableBox.left, top: shown.rowBox.top + shown.rowBox.height / 2 }}>
|
|
196
|
+
<Menu
|
|
197
|
+
open={menu === 'row'}
|
|
198
|
+
onOpenChange={(open) => setMenu(open ? 'row' : null)}
|
|
199
|
+
trigger={
|
|
200
|
+
<IconButton aria-label="Row menu" tooltip="Row" className="h-6 w-4 p-0">
|
|
201
|
+
<IconGripVertical size={16} stroke={1.5} />
|
|
202
|
+
</IconButton>
|
|
203
|
+
}
|
|
204
|
+
>
|
|
205
|
+
{/* Nothing goes above a header row: it is the table's first row, and a header anywhere else is no header (RichText, the agent's text). */}
|
|
206
|
+
{!isHeaderRow(shown.row) && <MenuItem label="Insert above" leading={<IconRowInsertTop {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addRowBefore))} />}
|
|
207
|
+
<MenuItem label="Insert below" leading={<IconRowInsertBottom {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addRowAfter))} />
|
|
208
|
+
<MenuSeparator />
|
|
209
|
+
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => deleteTableRow(editor, shown.cell))} />
|
|
210
|
+
<MenuItem label="Delete table" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => inCell(editor, shown.cell, deleteTable))} />
|
|
211
|
+
</Menu>
|
|
212
|
+
</div>
|
|
213
|
+
{/* The column's handle, centred on the table's top edge. */}
|
|
214
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute -translate-x-1/2 -translate-y-1/2')} style={{ left: shown.columnBox.left + shown.columnBox.width / 2, top: shown.tableBox.top }}>
|
|
215
|
+
<Menu
|
|
216
|
+
open={menu === 'column'}
|
|
217
|
+
onOpenChange={(open) => setMenu(open ? 'column' : null)}
|
|
218
|
+
trigger={
|
|
219
|
+
<IconButton aria-label="Column menu" tooltip="Column" className="h-4 w-6 p-0">
|
|
220
|
+
<IconGripHorizontal size={16} stroke={1.5} />
|
|
221
|
+
</IconButton>
|
|
222
|
+
}
|
|
223
|
+
>
|
|
224
|
+
<MenuItem label="Insert left" leading={<IconColumnInsertLeft {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addColumnBefore))} />
|
|
225
|
+
<MenuItem label="Insert right" leading={<IconColumnInsertRight {...ICON} />} onClick={run(() => inCell(editor, shown.cell, addColumnAfter))} />
|
|
226
|
+
<MenuSeparator />
|
|
227
|
+
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => deleteTableColumn(editor, shown.cell))} />
|
|
228
|
+
<MenuItem label="Delete table" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={run(() => inCell(editor, shown.cell, deleteTable))} />
|
|
229
|
+
</Menu>
|
|
230
|
+
</div>
|
|
231
|
+
{/*
|
|
232
|
+
The ➕ bars, flush with the table so the pointer reaches them from a
|
|
233
|
+
cell without leaving it — the row's from the last row, the column's
|
|
234
|
+
from the last column. Anywhere else the one under the table would
|
|
235
|
+
lie over the top of the next block for nothing.
|
|
236
|
+
*/}
|
|
237
|
+
{shown.row === shown.table.rows[shown.table.rows.length - 1] && (
|
|
238
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute flex h-4')} style={{ left: shown.tableBox.left, top: shown.tableBox.top + shown.tableBox.height, width: shown.tableBox.width }}>
|
|
239
|
+
<IconButton aria-label="Add row" tooltip="Add row" className="h-full w-full p-0" onMouseDown={keepFocus} onClick={run(() => appendTableRow(editor, shown.table))}>
|
|
240
|
+
<IconPlus size={12} stroke={1.5} />
|
|
241
|
+
</IconButton>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
{shown.cell === shown.row.cells[shown.row.cells.length - 1] && (
|
|
245
|
+
<div data-table-control className={cn(HANDLE_BOX, 'absolute flex w-4')} style={{ left: shown.tableBox.left + shown.tableBox.width, top: shown.tableBox.top, height: shown.tableBox.height }}>
|
|
246
|
+
<IconButton aria-label="Add column" tooltip="Add column" className="h-full w-full p-0" onMouseDown={keepFocus} onClick={run(() => appendTableColumn(editor, shown.table))}>
|
|
247
|
+
<IconPlus size={12} stroke={1.5} />
|
|
248
|
+
</IconButton>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
package/src/NavItem.mdx
CHANGED
|
@@ -30,6 +30,14 @@ count — a muted mono number whose tooltip says what it counts.
|
|
|
30
30
|
|
|
31
31
|
<Canvas of={NavItemStories.WithMenu} />
|
|
32
32
|
|
|
33
|
+
- One thing done often enough to stay in sight — take the row off a list —
|
|
34
|
+
is `action`: an icon, its tooltip and its handler, the shape of a
|
|
35
|
+
**SectionHeader**'s actions. It shows with the ⋮, in the count's place, and
|
|
36
|
+
before the ⋮ when the row has a menu too. One, never a row of them: a second
|
|
37
|
+
thing to do goes in the menu.
|
|
38
|
+
|
|
39
|
+
<Canvas of={NavItemStories.WithAction} />
|
|
40
|
+
|
|
33
41
|
- A row with rows under it takes them as `children`: more `NavItem`s. The
|
|
34
42
|
row stays a link; while it is pointed at or focused, its icon becomes the
|
|
35
43
|
arrow that folds them, in the icon's own place, and they slide shut. For a
|
|
@@ -77,6 +85,8 @@ import { NavItem } from '@estiva-app/ui'
|
|
|
77
85
|
- The label truncates; the count never gives up its width.
|
|
78
86
|
- `menu` is the items only: `menu={<MenuItem label="Move to Folder…" onClick={move} />}`.
|
|
79
87
|
The ⋮'s name is `More options for {label}` unless `menuLabel` says otherwise.
|
|
88
|
+
- `action` is `{ icon, tooltip, onClick }`: `action={{ icon: <IconX size={16} stroke={1.5} />, tooltip: 'Remove from list', onClick: remove }}`.
|
|
89
|
+
Its name is `{tooltip}: {label}`.
|
|
80
90
|
- `children` start open; `defaultOpen={false}` starts them folded, and
|
|
81
91
|
`open` with `onOpenChange` hands the state to the app. The arrow's name is
|
|
82
92
|
`Hide what is under {label}` or `Show what is under {label}`.
|
package/src/NavItem.stories.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { IconSquareRounded } from '@tabler/icons-react'
|
|
2
|
+
import { IconSquareRounded, IconX } from '@tabler/icons-react'
|
|
3
3
|
import { Chip } from './Chip'
|
|
4
4
|
import { MenuItem } from './Menu'
|
|
5
5
|
import { NavItem } from './NavItem'
|
|
@@ -109,3 +109,25 @@ export const UnreadUrgent: Story = {
|
|
|
109
109
|
export const UnreadWithMenu: Story = {
|
|
110
110
|
args: { icon: placeholder, unread: true, menu: MENU },
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
const REMOVE = { icon: <IconX size={16} stroke={1.5} />, tooltip: 'Remove from list', onClick: () => {} }
|
|
114
|
+
|
|
115
|
+
/** One button of its own, in sight rather than in a menu: point at the row and it takes the count's place, as the ⋮ does. */
|
|
116
|
+
export const WithAction: Story = {
|
|
117
|
+
args: { icon: placeholder, action: REMOVE },
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The button and the ⋮ together: the button sits before it, and a long label stops short of both. */
|
|
121
|
+
export const WithActionAndMenu: Story = {
|
|
122
|
+
args: { label: 'An item whose label runs much longer than the column has room for', icon: placeholder, action: REMOVE, menu: MENU },
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A hint and a button: point at it and the chip shows, then the button — a row whose state is a word (here, archived) and which can be taken off the list. */
|
|
126
|
+
export const WithActionAndHint: Story = {
|
|
127
|
+
args: { label: 'An item whose label runs much longer than the column has room for', icon: placeholder, action: REMOVE, hint: <Chip type="neutral" label="Archived" /> },
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Unread, with a button: point at it and the button lands on the dot. */
|
|
131
|
+
export const UnreadWithAction: Story = {
|
|
132
|
+
args: { icon: placeholder, unread: true, action: REMOVE },
|
|
133
|
+
}
|
package/src/NavItem.test.tsx
CHANGED
|
@@ -141,3 +141,39 @@ describe('NavItem unread', () => {
|
|
|
141
141
|
expect(menu?.className).toContain('right-1')
|
|
142
142
|
})
|
|
143
143
|
})
|
|
144
|
+
|
|
145
|
+
describe('NavItem with a button of its own', () => {
|
|
146
|
+
const remove = (onClick = () => {}) => ({ icon: <svg />, tooltip: 'Remove from list', onClick })
|
|
147
|
+
|
|
148
|
+
it('draws the button beside the link, named for its row, and a click runs it, not the link', async () => {
|
|
149
|
+
const onClick = vi.fn()
|
|
150
|
+
const onLink = vi.fn()
|
|
151
|
+
render(<NavItem href="#" label="Item one" action={remove(onClick)} onClick={onLink} />)
|
|
152
|
+
const button = screen.getByRole('button', { name: 'Remove from list: Item one' })
|
|
153
|
+
expect(button.closest('a')).toBeNull()
|
|
154
|
+
await userEvent.click(button)
|
|
155
|
+
expect(onClick).toHaveBeenCalledOnce()
|
|
156
|
+
expect(onLink).not.toHaveBeenCalled()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('shows with the row, as the ⋮ does: in its place at right-1, always on a screen with no hover', () => {
|
|
160
|
+
render(<NavItem href="#" label="Item one" action={remove()} />)
|
|
161
|
+
const box = screen.getByRole('button', { name: 'Remove from list: Item one' }).closest('[data-nav-menu]')!
|
|
162
|
+
expect(box.className.split(' ')).toEqual(expect.arrayContaining(['right-1', 'opacity-0', 'group-hover/nav:opacity-100', '[@media(hover:none)]:opacity-100']))
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('with a menu too, sits before the ⋮, and the row keeps room for both while they show', () => {
|
|
166
|
+
const { container } = render(<NavItem href="#" label="Item one" action={remove()} menu={<span>Action</span>} />)
|
|
167
|
+
const buttons = [...container.querySelectorAll('[data-nav-menu] button')].map((b) => b.getAttribute('aria-label'))
|
|
168
|
+
expect(buttons).toEqual(['Remove from list: Item one', 'More options for Item one'])
|
|
169
|
+
const kept = [...container.querySelectorAll('a > [aria-hidden="true"]')].map((s) => s.className.split(' '))
|
|
170
|
+
expect(kept).toHaveLength(2)
|
|
171
|
+
expect(kept[0]).toContain('w-5')
|
|
172
|
+
expect(kept[1]).toContain('w-6')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('lands on the dot, which steps aside for it', () => {
|
|
176
|
+
const { container } = render(<NavItem href="#" label="Item one" unread action={remove()} />)
|
|
177
|
+
expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('group-hover/nav:opacity-0')
|
|
178
|
+
})
|
|
179
|
+
})
|