@estiva-app/ui 0.66.0 → 0.68.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 +12 -1
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/EditorTableControls.d.ts +38 -0
- package/dist/EditorTableControls.d.ts.map +1 -0
- package/dist/LinkChips.d.ts +82 -0
- package/dist/LinkChips.d.ts.map +1 -0
- package/dist/NavItem.d.ts +11 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/ReferenceCards.d.ts +77 -0
- package/dist/ReferenceCards.d.ts.map +1 -0
- package/dist/ReferenceMenu.d.ts +18 -0
- package/dist/ReferenceMenu.d.ts.map +1 -0
- package/dist/RichText.d.ts +9 -2
- package/dist/RichText.d.ts.map +1 -1
- package/dist/SelectionToolbar.d.ts +9 -1
- package/dist/SelectionToolbar.d.ts.map +1 -1
- package/dist/editor.d.ts +4 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +750 -27
- package/dist/editor.js.map +4 -4
- package/dist/index.js +62 -33
- package/dist/index.js.map +4 -4
- package/package.json +2 -1
- package/registry.json +414 -4
- package/src/BlockNodes.test.tsx +2 -1
- package/src/BlockNodes.tsx +51 -9
- 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/LinkChips.mdx +57 -0
- package/src/LinkChips.stories.tsx +67 -0
- package/src/LinkChips.test.tsx +178 -0
- package/src/LinkChips.ts +243 -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/ReferenceCards.mdx +79 -0
- package/src/ReferenceCards.stories.tsx +64 -0
- package/src/ReferenceCards.test.tsx +143 -0
- package/src/ReferenceCards.ts +225 -0
- package/src/ReferenceMenu.mdx +75 -0
- package/src/ReferenceMenu.stories.tsx +59 -0
- package/src/ReferenceMenu.test.tsx +289 -0
- package/src/ReferenceMenu.tsx +346 -0
- package/src/RichText.test.tsx +17 -0
- package/src/RichText.tsx +36 -4
- package/src/SelectionToolbar.tsx +10 -2
- package/src/editor.ts +14 -0
- package/src/pages.test.ts +3 -0
- package/src/registry/registry.test.ts +7 -7
- package/src/registry/siblings.test.ts +2 -2
- package/stories/Choosing.mdx +4 -0
package/src/LinkChips.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app URL in a document reads as the chip of what it names — SPEC §13.3,
|
|
3
|
+
* "A reference's card", and §7.7.
|
|
4
|
+
*
|
|
5
|
+
* The URL stays the URL: the chip is the `reference` node with `href` set (a
|
|
6
|
+
* URL chip, `ReferenceNode`), which a document writes back as the URL it was,
|
|
7
|
+
* plain text with no card and a `link` mark with one. Never a `nostr:`
|
|
8
|
+
* reference in its place: the URL is how a person addresses the object.
|
|
9
|
+
*
|
|
10
|
+
* What a URL names is the app's to say — its manifests and its relay — so the
|
|
11
|
+
* app hands this extension two functions: `referenceOf`, what it has resolved
|
|
12
|
+
* so far, and `ask`, to look up the URLs it has not. A URL that names nothing,
|
|
13
|
+
* or whose answer has not come back, stays text; when `ask` settles the
|
|
14
|
+
* document is looked at again.
|
|
15
|
+
*
|
|
16
|
+
* The URL being typed stays text while the caret is in it or at its end, so a
|
|
17
|
+
* half-typed address never turns into a chip under the person's fingers. A
|
|
18
|
+
* pasted URL is whole, so it is asked about and made a chip at once, the caret
|
|
19
|
+
* still at its end — where `ReferenceMenu` opens its menu.
|
|
20
|
+
*/
|
|
21
|
+
import { Extension, type Editor } from '@tiptap/core'
|
|
22
|
+
import type { Node as PmNode } from '@tiptap/pm/model'
|
|
23
|
+
import { Plugin, PluginKey, type EditorState, type Selection, type Transaction } from '@tiptap/pm/state'
|
|
24
|
+
|
|
25
|
+
export interface LinkChipsOptions {
|
|
26
|
+
/**
|
|
27
|
+
* The `nostr:` reference an app URL names, from what the app has resolved
|
|
28
|
+
* (§7.7) — protocol's `referenceOfLink(url, resolveLink)`. Undefined while it
|
|
29
|
+
* names nothing, or its answer has not come back.
|
|
30
|
+
*/
|
|
31
|
+
referenceOf: ((url: string) => string | undefined) | null
|
|
32
|
+
/** Look up what these URLs name. When it settles, the document is looked at again. */
|
|
33
|
+
ask: ((urls: string[]) => Promise<unknown> | void) | null
|
|
34
|
+
/**
|
|
35
|
+
* How many URLs one editor asks about in its life, the first ones; the rest
|
|
36
|
+
* stay text. An app's answer can cost a relay query, and a document pasted
|
|
37
|
+
* full of links must not spend the writer's quota. Default 24.
|
|
38
|
+
*/
|
|
39
|
+
limit: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface LinkChipsState {
|
|
43
|
+
options: LinkChipsOptions
|
|
44
|
+
/** The last few pastes, mapped through every edit since. */
|
|
45
|
+
pasted: PastedRange[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const key = new PluginKey<LinkChipsState>('linkChips')
|
|
49
|
+
|
|
50
|
+
/** The meta on the transaction that made chips: `{ pasted }`. */
|
|
51
|
+
export const CHIPS_MADE = 'linkChipsMade'
|
|
52
|
+
|
|
53
|
+
/** How many pastes are remembered; one is what the person just did. */
|
|
54
|
+
const PASTES_KEPT = 8
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A link on words in marker text (skipped), or a bare URL — the grammar of
|
|
58
|
+
* protocol's `urlRunsIn`, which a reader turns into chips by, so what an editor
|
|
59
|
+
* makes a chip is what a reader would.
|
|
60
|
+
*/
|
|
61
|
+
const LINK_OR_URL = /(\[[^[\]\n]+\]\((?:https?:\/\/|mailto:)[^\s)]+\))|(https?:\/\/[^\s<>"'`]+)/g
|
|
62
|
+
const TRAILING = new Set(['.', ',', ';', ':', '!', '?', ')', ']'])
|
|
63
|
+
|
|
64
|
+
/** Each URL and where it sits; `end` is where its run ends before trailing punctuation is trimmed, which typing may yet continue. */
|
|
65
|
+
function urlRuns(text: string): { url: string; from: number; to: number; end: number }[] {
|
|
66
|
+
if (!text.includes('://')) return []
|
|
67
|
+
const out: { url: string; from: number; to: number; end: number }[] = []
|
|
68
|
+
for (const m of text.matchAll(LINK_OR_URL)) {
|
|
69
|
+
if (m[1]) continue
|
|
70
|
+
const start = m.index
|
|
71
|
+
if (text.slice(Math.max(0, start - 2), start) === '](' || text[start - 1] === '[') continue
|
|
72
|
+
let end = m[0].length
|
|
73
|
+
while (end > 0 && TRAILING.has(m[0][end - 1])) end--
|
|
74
|
+
out.push({ url: m[0].slice(0, end), from: start, to: start + end, end: start + m[0].length })
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Found {
|
|
80
|
+
/** Text URLs that name something: made chips. */
|
|
81
|
+
chips: { from: number; to: number; url: string; uri: string; marks: string[]; pasted: boolean }[]
|
|
82
|
+
/** URL chips whose URL is answered now: given their reference. */
|
|
83
|
+
fills: { pos: number; uri: string }[]
|
|
84
|
+
/** Every URL still waiting for an answer, or naming nothing. */
|
|
85
|
+
open: string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Where something was pasted: a URL wholly inside one was not typed, so the caret at its end does not hold it as text. */
|
|
89
|
+
export interface PastedRange {
|
|
90
|
+
from: number
|
|
91
|
+
to: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What to change in a document, and what to ask. `selection` null treats no
|
|
96
|
+
* URL as being typed; a URL inside one of `pasted` is never being typed.
|
|
97
|
+
*/
|
|
98
|
+
export function findLinkChips(doc: PmNode, selection: Selection | null, referenceOf: LinkChipsOptions['referenceOf'], pasted: readonly PastedRange[] = []): Found {
|
|
99
|
+
const found: Found = { chips: [], fills: [], open: [] }
|
|
100
|
+
const open = new Set<string>()
|
|
101
|
+
doc.descendants((node, pos) => {
|
|
102
|
+
// A fence is literal (§13.1): its URLs are text.
|
|
103
|
+
if (node.type.spec.code) return false
|
|
104
|
+
if (node.type.name === 'reference') {
|
|
105
|
+
const href = typeof node.attrs.href === 'string' ? node.attrs.href : ''
|
|
106
|
+
if (href && !node.attrs.uri) {
|
|
107
|
+
const uri = referenceOf?.(href)
|
|
108
|
+
if (uri) found.fills.push({ pos, uri })
|
|
109
|
+
else open.add(href)
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
if (!node.isText || !node.text) return true
|
|
114
|
+
// A URL in code is literal, and one already linked on purpose is the link's.
|
|
115
|
+
if (node.marks.some((m) => m.type.spec.code || m.type.name === 'code' || m.type.name === 'link')) return false
|
|
116
|
+
for (const run of urlRuns(node.text)) {
|
|
117
|
+
const from = pos + run.from
|
|
118
|
+
const to = pos + run.to
|
|
119
|
+
// Being typed or selected: the caret inside it or at its end (`https://a.` is still being typed), or a
|
|
120
|
+
// selection over any of it. A caret before it is not.
|
|
121
|
+
// A pasted URL is whole: it becomes its chip with the caret still at its end, so its menu can open at once.
|
|
122
|
+
const wasPasted = pasted.some((range) => range.from <= from && pos + run.end <= range.to)
|
|
123
|
+
const typing = selection && selection.to > from && selection.from <= pos + run.end
|
|
124
|
+
if (typing && !(selection.empty && wasPasted)) continue
|
|
125
|
+
const uri = referenceOf?.(run.url)
|
|
126
|
+
if (uri) found.chips.push({ from, to, url: run.url, uri, marks: node.marks.map((m) => m.type.name), pasted: wasPasted })
|
|
127
|
+
else open.add(run.url)
|
|
128
|
+
}
|
|
129
|
+
return false
|
|
130
|
+
})
|
|
131
|
+
found.open = [...open]
|
|
132
|
+
return found
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function apply(state: EditorState, found: Found): Transaction | null {
|
|
136
|
+
if (!found.chips.length && !found.fills.length) return null
|
|
137
|
+
const type = state.schema.nodes.reference
|
|
138
|
+
if (!type) return null
|
|
139
|
+
const tr = state.tr
|
|
140
|
+
// A fill keeps the node's size, so the chips' positions still hold; the chips go last first.
|
|
141
|
+
for (const { pos, uri } of found.fills) {
|
|
142
|
+
const node = tr.doc.nodeAt(pos)
|
|
143
|
+
if (node) tr.setNodeMarkup(pos, undefined, { ...node.attrs, uri })
|
|
144
|
+
}
|
|
145
|
+
for (const chip of [...found.chips].reverse()) {
|
|
146
|
+
tr.replaceWith(chip.from, chip.to, type.create({ uri: chip.uri, href: chip.url, marks: chip.marks }))
|
|
147
|
+
}
|
|
148
|
+
// Not a step of the person's: undo takes back what they typed, not the chip it became.
|
|
149
|
+
// Whether a pasted URL became its chip: `ReferenceMenu` opens on that one, never on a URL typed by hand.
|
|
150
|
+
return tr.setMeta('addToHistory', false).setMeta(CHIPS_MADE, { pasted: found.chips.some((chip) => chip.pasted) })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Look at the document again — after the app learned what more URLs name some
|
|
155
|
+
* other way. Dispatches only when there is a chip to make: an empty
|
|
156
|
+
* transaction would wake every other plugin (a trailing paragraph appears).
|
|
157
|
+
*/
|
|
158
|
+
export function refreshLinkChips(editor: Editor): void {
|
|
159
|
+
if (editor.isDestroyed) return
|
|
160
|
+
const chips = key.getState(editor.state)
|
|
161
|
+
if (!chips) return
|
|
162
|
+
const tr = apply(editor.state, findLinkChips(editor.state.doc, editor.state.selection, chips.options.referenceOf, chips.pasted))
|
|
163
|
+
if (tr) editor.view.dispatch(tr)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turns the app URLs in a document into URL chips. Pair it with `ReferenceNode`;
|
|
168
|
+
* `ReferenceCards` then draws a card a URL chip asks for.
|
|
169
|
+
*/
|
|
170
|
+
export const LinkChips = Extension.create<LinkChipsOptions>({
|
|
171
|
+
name: 'linkChips',
|
|
172
|
+
addOptions() {
|
|
173
|
+
return { referenceOf: null, ask: null, limit: 24 }
|
|
174
|
+
},
|
|
175
|
+
addProseMirrorPlugins() {
|
|
176
|
+
const options = this.options
|
|
177
|
+
const editor = this.editor
|
|
178
|
+
return [
|
|
179
|
+
new Plugin<LinkChipsState>({
|
|
180
|
+
key,
|
|
181
|
+
// The options, where `refreshLinkChips` can read them from the state alone, and where the last pastes landed.
|
|
182
|
+
state: {
|
|
183
|
+
init: () => ({ options, pasted: [] }),
|
|
184
|
+
apply: (tr, value, before, after) => {
|
|
185
|
+
if (!tr.docChanged) return value
|
|
186
|
+
// A paste edited inside is being typed again: forgotten, so each keystroke in it is not an ask (security review).
|
|
187
|
+
const pasted = value.pasted
|
|
188
|
+
.map((range) => ({ from: tr.mapping.map(range.from, 1), to: tr.mapping.map(range.to, -1), size: range.to - range.from }))
|
|
189
|
+
.filter((range) => range.from < range.to && range.to - range.from === range.size)
|
|
190
|
+
.map(({ from, to }) => ({ from, to }))
|
|
191
|
+
if (tr.getMeta('uiEvent') === 'paste') {
|
|
192
|
+
const from = tr.mapping.map(before.selection.from, -1)
|
|
193
|
+
if (from < after.selection.to) pasted.push({ from, to: after.selection.to })
|
|
194
|
+
}
|
|
195
|
+
return { options, pasted: pasted.slice(-PASTES_KEPT) }
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
appendTransaction: (trs, _old, state) => {
|
|
199
|
+
if (!trs.some((tr) => tr.docChanged || tr.selectionSet)) return null
|
|
200
|
+
return apply(state, findLinkChips(state.doc, state.selection, options.referenceOf, key.getState(state)?.pasted))
|
|
201
|
+
},
|
|
202
|
+
view: (view) => {
|
|
203
|
+
// Each URL is asked once; one whose ask failed is forgotten, so a later look asks again.
|
|
204
|
+
const asked = new Set<string>()
|
|
205
|
+
// The URL being typed is not asked about: each prefix of it would spend one of `limit`, and a query.
|
|
206
|
+
const look = (state: EditorState) => {
|
|
207
|
+
if (!options.ask) return
|
|
208
|
+
const { open } = findLinkChips(state.doc, state.selection, options.referenceOf, key.getState(state)?.pasted)
|
|
209
|
+
const fresh = open.filter((url) => !asked.has(url)).slice(0, Math.max(0, options.limit - asked.size))
|
|
210
|
+
if (!fresh.length) return
|
|
211
|
+
for (const url of fresh) asked.add(url)
|
|
212
|
+
const forget = () => {
|
|
213
|
+
for (const url of fresh) asked.delete(url)
|
|
214
|
+
}
|
|
215
|
+
let answer: Promise<unknown> | void
|
|
216
|
+
try {
|
|
217
|
+
answer = options.ask(fresh)
|
|
218
|
+
} catch {
|
|
219
|
+
forget()
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
void Promise.resolve(answer).then(() => refreshLinkChips(editor), forget)
|
|
223
|
+
}
|
|
224
|
+
look(view.state)
|
|
225
|
+
// Once the view is up, so a dispatch lands: what the app already knows turns into chips on open.
|
|
226
|
+
queueMicrotask(() => {
|
|
227
|
+
try {
|
|
228
|
+
refreshLinkChips(editor)
|
|
229
|
+
} catch {
|
|
230
|
+
// Unmounted before it ran.
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
return {
|
|
234
|
+
update: (next, prev) => {
|
|
235
|
+
// A caret leaving a URL finishes it, so a move is looked at as an edit is.
|
|
236
|
+
if (next.state.doc !== prev.doc || !next.state.selection.eq(prev.selection)) look(next.state)
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
]
|
|
242
|
+
},
|
|
243
|
+
})
|
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>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ReferenceCardsStories from './ReferenceCards.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ReferenceCardsStories} />
|
|
5
|
+
|
|
6
|
+
# ReferenceCards
|
|
7
|
+
|
|
8
|
+
A chip in a document can ask for a card after its line: compact, detailed or
|
|
9
|
+
none. The choice is the chip's own `embed`, so the card moves and goes with it.
|
|
10
|
+
This draws no card itself. It places a slot after each line that asks for
|
|
11
|
+
one, and the app draws its own card into it.
|
|
12
|
+
|
|
13
|
+
<Canvas of={ReferenceCardsStories.InAnEditor} />
|
|
14
|
+
|
|
15
|
+
## When
|
|
16
|
+
|
|
17
|
+
- **An editor of a document whose chips can carry a card**: pair it with
|
|
18
|
+
**ReferenceNode**, which holds the choice, and **SelectionToolbar**, where
|
|
19
|
+
the app offers it for the selected chip.
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- A document that is only read → **RichText**, with each block's cards drawn
|
|
24
|
+
after it from the text's own description of them.
|
|
25
|
+
- A text whose cards travel beside it rather than on its chips → the app keeps
|
|
26
|
+
that choice itself, and this has nothing to read.
|
|
27
|
+
|
|
28
|
+
## How
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { createPortal } from 'react-dom'
|
|
32
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
33
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
34
|
+
import { ToolbarButton } from '@estiva-app/ui'
|
|
35
|
+
import {
|
|
36
|
+
BlockId,
|
|
37
|
+
ReferenceCards,
|
|
38
|
+
ReferenceNode,
|
|
39
|
+
SelectionToolbar,
|
|
40
|
+
referenceAtSelection,
|
|
41
|
+
setReferenceCard,
|
|
42
|
+
useReferenceCards,
|
|
43
|
+
type ReferenceCard,
|
|
44
|
+
type ReferenceViewProps,
|
|
45
|
+
} from '@estiva-app/ui/editor'
|
|
46
|
+
|
|
47
|
+
const Reference = ({ uri }: ReferenceViewProps) => <span>{uri}</span>
|
|
48
|
+
const Cards = ({ cards }: { cards: ReferenceCard[] }) => <div>{cards.map((card) => card.layout).join(', ')}</div>
|
|
49
|
+
|
|
50
|
+
const editor = useEditor({ extensions: [StarterKit, BlockId, ReferenceNode.configure({ view: Reference }), ReferenceCards] })
|
|
51
|
+
const slots = useReferenceCards(editor)
|
|
52
|
+
const chip = editor ? referenceAtSelection(editor.state) : null
|
|
53
|
+
|
|
54
|
+
;<>
|
|
55
|
+
<EditorContent editor={editor} />
|
|
56
|
+
{slots.map((slot) => createPortal(<Cards cards={slot.cards} />, slot.element, slot.key))}
|
|
57
|
+
<SelectionToolbar editor={editor}>
|
|
58
|
+
{chip && editor && (
|
|
59
|
+
<ToolbarButton aria-label="Detailed" pressed={chip.layout === 'detailed'} onMouseDown={(e) => e.preventDefault()} onClick={() => setReferenceCard(editor, chip.layout === 'detailed' ? 'none' : 'detailed')}>
|
|
60
|
+
D
|
|
61
|
+
</ToolbarButton>
|
|
62
|
+
)}
|
|
63
|
+
</SelectionToolbar>
|
|
64
|
+
</>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **A slot keeps its element while its line lives.** It is keyed by the
|
|
68
|
+
line's block id, so typing above a card does not make it load again.
|
|
69
|
+
- **The slot is not text.** It cannot be typed into or selected, and a click
|
|
70
|
+
on a card stays the card's.
|
|
71
|
+
- **A layout this version does not know draws as compact.** The writer asked
|
|
72
|
+
for a card, and the smallest is the safe reading.
|
|
73
|
+
- **Press without taking the focus.** A button in the toolbar prevents its
|
|
74
|
+
press's default, or the editor loses the chip's selection and the toolbar
|
|
75
|
+
goes.
|
|
76
|
+
|
|
77
|
+
## What it owns
|
|
78
|
+
|
|
79
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
// @estiva-escape(no-hand-rolled-behaviour): the slots ReferenceCards places inside the document are drawn into by a portal, which keeps the app's providers around its card
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
4
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
6
|
+
import { BlockId, ReferenceNode } from './BlockNodes'
|
|
7
|
+
import { Card } from './Card'
|
|
8
|
+
import { ReferenceCards, useReferenceCards } from './ReferenceCards'
|
|
9
|
+
import { richTextClassName } from './RichText'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A chip that asks for a card gets a slot after its line, and the app draws
|
|
13
|
+
* its card there. Here the "card" is a stand-in box naming the layout: the real
|
|
14
|
+
* one is the app's, which knows how to resolve what the chip points at.
|
|
15
|
+
*/
|
|
16
|
+
const meta = {
|
|
17
|
+
title: 'Primitives/ReferenceCards',
|
|
18
|
+
parameters: { layout: 'padded' },
|
|
19
|
+
} satisfies Meta
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj
|
|
23
|
+
|
|
24
|
+
const ref = (uri: string, embed?: string) => ({ type: 'reference', attrs: { uri, ...(embed ? { embed } : {}) } })
|
|
25
|
+
|
|
26
|
+
function Box() {
|
|
27
|
+
const editor = useEditor({
|
|
28
|
+
extensions: [StarterKit, BlockId, ReferenceNode, ReferenceCards],
|
|
29
|
+
content: {
|
|
30
|
+
type: 'doc',
|
|
31
|
+
content: [
|
|
32
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'Blocked by ' }, ref('nostr:naddr1first', 'detailed'), { type: 'text', text: ' until it ships.' }] },
|
|
33
|
+
{ type: 'paragraph', attrs: { blockId: 'p2' }, content: [{ type: 'text', text: 'See also ' }, ref('nostr:naddr1second')] },
|
|
34
|
+
{ type: 'paragraph', attrs: { blockId: 'p3' }, content: [ref('nostr:naddr1third', 'compact')] },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
38
|
+
})
|
|
39
|
+
const slots = useReferenceCards(editor)
|
|
40
|
+
// @estiva-escape(no-hand-rolled-behaviour): the app's card goes into the slot ReferenceCards placed after its line, inside the document; no Base UI part owns a place in an editor's text
|
|
41
|
+
return (
|
|
42
|
+
<Card fill="surface" className="flex w-[520px] flex-col gap-2 p-3">
|
|
43
|
+
<EditorContent editor={editor} />
|
|
44
|
+
{slots.map((slot) =>
|
|
45
|
+
createPortal(
|
|
46
|
+
<div className="mt-2 flex flex-col gap-2">
|
|
47
|
+
{slot.cards.map((card, i) => (
|
|
48
|
+
<Card key={`${card.uri}-${i}`} fill="surface" className="p-3">
|
|
49
|
+
<span className="text-body-2 text-text-secondary">
|
|
50
|
+
{card.layout} card for {card.uri}
|
|
51
|
+
</span>
|
|
52
|
+
</Card>
|
|
53
|
+
))}
|
|
54
|
+
</div>,
|
|
55
|
+
slot.element,
|
|
56
|
+
slot.key,
|
|
57
|
+
),
|
|
58
|
+
)}
|
|
59
|
+
</Card>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Three chips: a detailed card after the first line, none for the second, a compact one after the third. */
|
|
64
|
+
export const InAnEditor: Story = { render: () => <Box /> }
|