@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/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.map +1 -1
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +165 -11
- package/dist/editor.js.map +4 -4
- package/dist/index.js +23 -14
- package/dist/index.js.map +2 -2
- package/package.json +2 -1
- package/registry.json +127 -4
- 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.tsx +2 -1
- package/src/editor.ts +1 -0
- package/src/registry/registry.test.ts +6 -6
- package/src/registry/siblings.test.ts +2 -2
- package/stories/Choosing.mdx +1 -0
|
@@ -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
|
+
})
|
package/src/NavItem.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { cn } from './cn'
|
|
|
6
6
|
import { COLLAPSIBLE_PANEL_CLASSES, COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
7
7
|
import { IconButton } from './IconButton'
|
|
8
8
|
import { Menu } from './Menu'
|
|
9
|
+
import type { SectionAction } from './SectionHeader'
|
|
9
10
|
import { UnreadDot } from './UnreadDot'
|
|
10
11
|
import { WithTooltip } from './Tooltip'
|
|
11
12
|
|
|
@@ -33,6 +34,13 @@ import { WithTooltip } from './Tooltip'
|
|
|
33
34
|
* link, like the ⋮. The rows slide as `CollapsibleSection`'s do, indented
|
|
34
35
|
* past the icon so their icons start under this row's label.
|
|
35
36
|
*
|
|
37
|
+
* `action` is one button of its own beside the ⋮ — any icon, with its tooltip
|
|
38
|
+
* (Katerina, 14 October: Peek's Open work keeps its ✕ "Remove from list" in
|
|
39
|
+
* sight, not inside a menu). The same shape as a `SectionHeader`'s actions,
|
|
40
|
+
* and drawn the same way: an `IconButton` that shows with the ⋮, in the
|
|
41
|
+
* count's place, before the ⋮ when there is one. One, never a row of them: a
|
|
42
|
+
* second thing to do goes in the menu.
|
|
43
|
+
*
|
|
36
44
|
* `hint` is shown only while the row is pointed at or focused, left of the
|
|
37
45
|
* count — a neutral `Chip`, typically. It takes its own room while it shows,
|
|
38
46
|
* so the label ends with "…" before it instead of running under it.
|
|
@@ -60,6 +68,8 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
|
|
|
60
68
|
menu?: ReactNode
|
|
61
69
|
/** The ⋮'s accessible name and tooltip. Default "More options for {label}". */
|
|
62
70
|
menuLabel?: string
|
|
71
|
+
/** One button beside the ⋮, shown with it: any 16px icon, its tooltip and its handler — a `SectionHeader` action's shape. With a menu too, it sits before the ⋮. */
|
|
72
|
+
action?: SectionAction
|
|
63
73
|
/** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
|
|
64
74
|
hint?: ReactNode
|
|
65
75
|
/** Something new here: brighter words, the label in medium, and the `UnreadDot` at the right. Say so in the row's own words for assistive tech too. */
|
|
@@ -85,6 +95,7 @@ export function NavItem({
|
|
|
85
95
|
icon,
|
|
86
96
|
menu,
|
|
87
97
|
menuLabel,
|
|
98
|
+
action,
|
|
88
99
|
hint,
|
|
89
100
|
unread = false,
|
|
90
101
|
urgent = false,
|
|
@@ -107,7 +118,8 @@ export function NavItem({
|
|
|
107
118
|
}
|
|
108
119
|
// The row draws beside it — the ⋮, the arrow — so the pointer can leave the
|
|
109
120
|
// link for them while the row still reads as the one it is on.
|
|
110
|
-
const
|
|
121
|
+
const buttons = (menu != null ? 1 : 0) + (action ? 1 : 0)
|
|
122
|
+
const besides = buttons > 0 || hasRows
|
|
111
123
|
// What sits at the right at rest, after the count: the dot, or the badge.
|
|
112
124
|
const mark = unread ? (urgent ? <UrgentMark /> : <UnreadDot />) : null
|
|
113
125
|
// Under the ⋮ while it shows: the count and the mark step aside for it.
|
|
@@ -117,7 +129,7 @@ export function NavItem({
|
|
|
117
129
|
// hidden (Katerina, 5 October).
|
|
118
130
|
// A screen with no hover shows the ⋮ always (below), so what it lands on
|
|
119
131
|
// steps aside always: the row reads as it does under a pointer.
|
|
120
|
-
const stepsAside =
|
|
132
|
+
const stepsAside = buttons > 0 && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0 [@media(hover:none)]:opacity-0'
|
|
121
133
|
|
|
122
134
|
const row = (
|
|
123
135
|
<a
|
|
@@ -134,7 +146,7 @@ export function NavItem({
|
|
|
134
146
|
// look while the ⋮ is pointed at and while its menu is open. The same
|
|
135
147
|
// for the arrow.
|
|
136
148
|
besides && !active && 'group-hover/nav:bg-bg-hover group-hover/nav:text-text-primary',
|
|
137
|
-
|
|
149
|
+
buttons > 0 && !active && 'group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:bg-bg-hover group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:text-text-primary',
|
|
138
150
|
className,
|
|
139
151
|
)}
|
|
140
152
|
{...props}
|
|
@@ -156,6 +168,11 @@ export function NavItem({
|
|
|
156
168
|
// Takes room only while it shows, so the label gives way to it.
|
|
157
169
|
<span className="hidden shrink-0 items-center group-hover/nav:flex group-has-[:focus-visible]/nav:flex">{hint}</span>
|
|
158
170
|
)}
|
|
171
|
+
{buttons > 1 && (
|
|
172
|
+
// Two buttons: the second one's room (24px and the 4px between them, the
|
|
173
|
+
// row's 8px gap making up the rest), kept only while they show.
|
|
174
|
+
<span aria-hidden="true" className="hidden w-5 shrink-0 group-hover/nav:block group-has-[:focus-visible]/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block [@media(hover:none)]:block" />
|
|
175
|
+
)}
|
|
159
176
|
{count ? (
|
|
160
177
|
<WithTooltip label={countLabel ?? `${count} open`}>
|
|
161
178
|
{/* A 16px box, centred: the slot is an icon's width, so a number
|
|
@@ -179,7 +196,7 @@ export function NavItem({
|
|
|
179
196
|
// of the ⋮ (8px from the edge to the 16px box), one column with a
|
|
180
197
|
// folder heading's dot and ⋮ (Katerina, 5 October).
|
|
181
198
|
<span className={cn('-mr-1 flex shrink-0', stepsAside)}>{mark}</span>
|
|
182
|
-
) :
|
|
199
|
+
) : buttons > 0 && !count ? (
|
|
183
200
|
// Nothing at the right, but a menu: no room is kept at rest, so the
|
|
184
201
|
// label has the row's whole width; while the ⋮ shows, its 24px slot
|
|
185
202
|
// is kept, so a long label ends before it instead of running under it.
|
|
@@ -210,7 +227,7 @@ export function NavItem({
|
|
|
210
227
|
</IconButton>
|
|
211
228
|
</div>
|
|
212
229
|
)}
|
|
213
|
-
{
|
|
230
|
+
{buttons > 0 && (
|
|
214
231
|
// `right-1`: the 24px button's 16px icon ends 8px from the row's
|
|
215
232
|
// edge, where a count ends (px-2) — and where a SectionHeader's
|
|
216
233
|
// actions and ⋮ end, so a folder's ⋮ and its rows' ⋮ are one column
|
|
@@ -219,17 +236,24 @@ export function NavItem({
|
|
|
219
236
|
// right of the slot's centre.
|
|
220
237
|
// On a screen with no hover it always shows: nothing there can point
|
|
221
238
|
// at the row, and the menu has no other way in.
|
|
222
|
-
<div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100')}>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
<div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center gap-1', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100')}>
|
|
240
|
+
{action && (
|
|
241
|
+
<IconButton tooltip={action.tooltip} aria-label={`${action.tooltip}: ${label}`} onClick={action.onClick}>
|
|
242
|
+
{action.icon}
|
|
243
|
+
</IconButton>
|
|
244
|
+
)}
|
|
245
|
+
{menu != null && (
|
|
246
|
+
<Menu
|
|
247
|
+
align="right"
|
|
248
|
+
trigger={
|
|
249
|
+
<IconButton tooltip="More options" aria-label={name}>
|
|
250
|
+
<IconDotsVertical size={16} stroke={1.5} />
|
|
251
|
+
</IconButton>
|
|
252
|
+
}
|
|
253
|
+
>
|
|
254
|
+
{menu}
|
|
255
|
+
</Menu>
|
|
256
|
+
)}
|
|
233
257
|
</div>
|
|
234
258
|
)}
|
|
235
259
|
</div>
|
package/src/RichText.tsx
CHANGED
|
@@ -125,7 +125,8 @@ const RICH_TEXT_CLASSES = {
|
|
|
125
125
|
syntax: '[&_: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',
|
|
126
126
|
/* A divider splits the text in two: 16px either side (Katerina, 9 October; 4 before). */
|
|
127
127
|
divider: '[&_hr]:my-2 [&_hr]:border-border-default',
|
|
128
|
-
table
|
|
128
|
+
/* A table 16px above and below, as a divider (Katerina, 14 October; 8 before): the editor's row ➕ sits in that room, not over the next line. */
|
|
129
|
+
table: '[&_: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',
|
|
129
130
|
/* Bold in the heading colour, not only heavier. An underlined word draws a faint line, so it does not read as a link (Katerina, 9 October). */
|
|
130
131
|
marks: '[&_em]:italic [&_strong]:font-semibold [&_strong]:text-text-primary [&_u]:underline [&_u]:decoration-text-muted [&_u]:underline-offset-2',
|
|
131
132
|
/* Lines 1.5 apart (Katerina, 9 October; 1.4 before). */
|
package/src/editor.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { PastedImages, pastedImages, pastedImageName, type PastedImagesOptions,
|
|
|
12
12
|
export { CodeBlockColours } from './CodeBlockColours'
|
|
13
13
|
export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
|
|
14
14
|
export { EditorBlockHandle, blockDropCursor, deleteBlock, moveBlock, duplicateBlock, startBlockDrag, type EditorBlockHandleProps, type BlockTurnInto } from './EditorBlockHandle'
|
|
15
|
+
export { EditorTableControls, appendTableRow, appendTableColumn, deleteTableRow, deleteTableColumn, type EditorTableControlsProps } from './EditorTableControls'
|
|
15
16
|
export {
|
|
16
17
|
PersonMention,
|
|
17
18
|
UrgentPersonMention,
|
|
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 149 names over 77 files: 110 components, 38 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October; CodeBlockColours, the coloured code block of the editor, on 9 October; Checklist, Callout, isChecklistTick and calloutLook (RIC-19) on 13 October; EditorTableControls and its four actions (RIC-21) on 13 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
55
|
// ComposerTriggers' pages.
|
|
56
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
57
|
-
expect(kinds).toEqual({ component:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
57
|
+
expect(kinds).toEqual({ component: 110, helper: 38, hook: 1 })
|
|
58
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(77)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
62
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
63
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
63
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(73)
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(76)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|
|
@@ -299,7 +299,7 @@ describe('the ids point at something', () => {
|
|
|
299
299
|
describe('a second parser agrees', async () => {
|
|
300
300
|
const docgen = await import('react-docgen').catch(() => null)
|
|
301
301
|
|
|
302
|
-
it.skipIf(!docgen)('finds the same props on all
|
|
302
|
+
it.skipIf(!docgen)('finds the same props on all 80 components', async () => {
|
|
303
303
|
if (!docgen) return
|
|
304
304
|
const resolver = new docgen.builtinResolvers.FindExportedDefinitionsResolver({ limit: 0 })
|
|
305
305
|
const byName = new Map(registry.entries.map((one) => [one.name, one]))
|
|
@@ -26,7 +26,7 @@ function appAt(folder: string, name: string, deps: Record<string, string> = UI,
|
|
|
26
26
|
put(`${folder}/src/${part}.tsx`, `/** The ${part.toLowerCase()} of ${name}. */\nexport function ${part}() {\n return <i />\n}\n`)
|
|
27
27
|
}
|
|
28
28
|
// A repository is a folder with `.git`: a directory, or a worktree's file.
|
|
29
|
-
appAt('alpha', 'alpha-app', UI, '
|
|
29
|
+
appAt('alpha', 'alpha-app', UI, 'Sundial')
|
|
30
30
|
put('alpha/.git/HEAD', 'ref: refs/heads/main\n')
|
|
31
31
|
put('beta/package.json', JSON.stringify({ name: 'beta' }))
|
|
32
32
|
put('beta/.git/HEAD', 'ref: refs/heads/main\n')
|
|
@@ -75,7 +75,7 @@ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', ()
|
|
|
75
75
|
})
|
|
76
76
|
|
|
77
77
|
it('answers the same from the folder that holds them', () => {
|
|
78
|
-
expect(find(workspace, '
|
|
78
|
+
expect(find(workspace, 'sundial')).toMatch(/^Sundial {2}· .* · {2}alpha/m)
|
|
79
79
|
expect(find(workspace, 'zzqqx')).toContain('Nothing in estiva-ui, alpha, beta matches')
|
|
80
80
|
})
|
|
81
81
|
|
package/stories/Choosing.mdx
CHANGED
|
@@ -73,6 +73,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
73
73
|
| A composer's `@`, `!@`, `[` and `/` — mention a person, reference something, format or insert | **ComposerTriggers** — `PersonMention`, `UrgentPersonMention`, `CaptionedReference`, `ReferenceTrigger` and `SlashCommands` from `@estiva-app/ui/editor`; the app passes its people, its items and its own `/` rows |
|
|
74
74
|
| An editor that saves a block document and must keep its block ids | **BlockNodes** — `BlockId`, `UnknownBlock`, `ReferenceNode` and `AttachmentNode` from `@estiva-app/ui/editor`, beside StarterKit and TableKit |
|
|
75
75
|
| Move, turn, duplicate or delete one block of a document being edited | **EditorBlockHandle**, from `@estiva-app/ui/editor`, with your rows after its own |
|
|
76
|
+
| Add or delete a table's rows and columns in a document being edited | **EditorTableControls**, from `@estiva-app/ui/editor`, inside the EditorBlockHandle |
|
|
76
77
|
| Offer a link or a comment on one block of a document being read | **BlockHandle** — your rows only, and no Tiptap loaded |
|
|
77
78
|
| Anything with a title, body and footer | **DialogShell** |
|
|
78
79
|
| "Are you sure?" | **ConfirmDialog** |
|