@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.
Files changed (43) hide show
  1. package/dist/BlockNodes.d.ts +33 -0
  2. package/dist/BlockNodes.d.ts.map +1 -1
  3. package/dist/ComposerTriggers.d.ts +11 -2
  4. package/dist/ComposerTriggers.d.ts.map +1 -1
  5. package/dist/EditorTableControls.d.ts +38 -0
  6. package/dist/EditorTableControls.d.ts.map +1 -0
  7. package/dist/NavItem.d.ts +11 -1
  8. package/dist/NavItem.d.ts.map +1 -1
  9. package/dist/RichText.d.ts +13 -1
  10. package/dist/RichText.d.ts.map +1 -1
  11. package/dist/editor.d.ts +2 -1
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +363 -18
  14. package/dist/editor.js.map +4 -4
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +71 -23
  18. package/dist/index.js.map +3 -3
  19. package/package.json +2 -1
  20. package/registry.json +209 -4
  21. package/src/BlockNodes.mdx +32 -1
  22. package/src/BlockNodes.stories.tsx +29 -1
  23. package/src/BlockNodes.test.tsx +141 -2
  24. package/src/BlockNodes.tsx +227 -4
  25. package/src/ComposerTriggers.test.tsx +23 -0
  26. package/src/ComposerTriggers.tsx +61 -5
  27. package/src/EditorTableControls.mdx +78 -0
  28. package/src/EditorTableControls.stories.tsx +63 -0
  29. package/src/EditorTableControls.test.tsx +288 -0
  30. package/src/EditorTableControls.tsx +255 -0
  31. package/src/NavItem.mdx +10 -0
  32. package/src/NavItem.stories.tsx +23 -1
  33. package/src/NavItem.test.tsx +36 -0
  34. package/src/NavItem.tsx +40 -16
  35. package/src/RichText.mdx +12 -0
  36. package/src/RichText.stories.tsx +50 -0
  37. package/src/RichText.test.tsx +56 -0
  38. package/src/RichText.tsx +83 -10
  39. package/src/editor.ts +4 -0
  40. package/src/index.ts +1 -1
  41. package/src/registry/registry.test.ts +7 -7
  42. package/src/registry/siblings.test.ts +2 -2
  43. 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}`.
@@ -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
+ }
@@ -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
+ })