@estiva-app/ui 0.73.0 → 0.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.73.0",
3
+ "version": "0.74.1",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.73.0",
7
+ "packageVersion": "0.74.1",
8
8
  "exports": 178,
9
9
  "typeExports": 164,
10
10
  "files": null
@@ -4395,6 +4395,12 @@
4395
4395
  "required": false,
4396
4396
  "note": "A second line under the label — a role, an address — 12px, secondary, truncating."
4397
4397
  },
4398
+ {
4399
+ "name": "aside",
4400
+ "takes": "text",
4401
+ "required": false,
4402
+ "note": "After the label, on its line — 12px, muted, never cut: where a file sits (an issue's ref and project) when its second line is its summary (SUM-7)."
4403
+ },
4398
4404
  {
4399
4405
  "name": "leading",
4400
4406
  "takes": "anything",
@@ -94,6 +94,12 @@ describe('CommandPaletteSearch — rows', () => {
94
94
  expect(groups.map((g) => g.firstElementChild?.textContent)).toEqual(['First', 'Second'])
95
95
  })
96
96
 
97
+ it('draws a row’s aside after its label, on the label’s line, above the second line (SUM-7)', () => {
98
+ render(<Search groups={[{ label: 'Recent', rows: [row('Ship launch', { aside: 'SHI-12 · Ship 1', description: 'What we show on Friday' })] }]} />)
99
+ const option = screen.getByRole('option')
100
+ expect(within(option).getByText('What we show on Friday').previousElementSibling?.textContent).toBe('Ship launchSHI-12 · Ship 1')
101
+ })
102
+
97
103
  it('puts 2px between its rows and under each heading, as a menu does', () => {
98
104
  render(<Search groups={[{ label: 'Group', rows: [row('One'), row('Two')] }]} />)
99
105
  const [first] = screen.getAllByRole('option')
@@ -196,8 +196,10 @@ export interface CommandPaletteRow {
196
196
  /** Unique in the level. The footer follows the lit row by it. */
197
197
  id: string
198
198
  label: string
199
- /** The second line — where it is, who said it, when. */
199
+ /** The second line — where it is, who said it, when; a file's summary. */
200
200
  description?: string
201
+ /** After the label, muted: where a file sits (an issue's ref and project) when its second line is its summary. */
202
+ aside?: string
201
203
  /** A 16px icon, drawn on the row's 32px tile. */
202
204
  icon?: ReactNode
203
205
  /** Instead of `icon`: something with a look of its own — a face, a status mark — in the same 32px space, with no tile. */
@@ -414,6 +416,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
414
416
  size="tall"
415
417
  label={row.label}
416
418
  description={row.description}
419
+ aside={row.aside}
417
420
  leading={<RowLeading row={row} />}
418
421
  submenu={!!row.onGoIn}
419
422
  hint={row.onGoIn ? <Kbd>Tab</Kbd> : <EnterHint />}
@@ -7,7 +7,7 @@
7
7
  import { afterEach, describe, expect, it } from 'vitest'
8
8
  import { Editor } from '@tiptap/core'
9
9
  import StarterKit from '@tiptap/starter-kit'
10
- import { BlockId } from './BlockNodes'
10
+ import { BlockId, ReferenceNode } from './BlockNodes'
11
11
  import { CommentHighlights, anchorOfSelection, commentPlace } from './CommentHighlights'
12
12
 
13
13
  let editor: Editor | undefined
@@ -62,7 +62,49 @@ describe('anchorOfSelection', () => {
62
62
  ])
63
63
  // The quote opens at 0, its first line's text at 2–10, the second's at 12–20.
64
64
  e.commands.setTextSelection({ from: 6, to: 15 })
65
- expect(anchorOfSelection(e)).toEqual({ blocks: ['q1'], quote: { start: 'linetwo' } })
65
+ // The quote's lines are joined with '\n', as the protocol reads the quote's text.
66
+ expect(anchorOfSelection(e)).toEqual({ blocks: ['q1'], quote: { start: 'line\ntwo' } })
67
+ })
68
+
69
+ it('quotes a line break inside a paragraph as a new line', () => {
70
+ const e = open([{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'A line' }, { type: 'hardBreak' }, { type: 'text', text: 'broken here' }] }])
71
+ // "A line" at 1–7, the break at 7, "broken here" at 8–19.
72
+ e.commands.setTextSelection({ from: 3, to: 14 })
73
+ expect(anchorOfSelection(e)).toEqual({ blocks: ['p1'], quote: { start: 'line\nbroken' } })
74
+ })
75
+
76
+ it('anchors nothing when only the edge between two lines of a quote, or a line break, is selected', () => {
77
+ const e = open([
78
+ { type: 'blockquote', attrs: { blockId: 'q1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one line' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'two line' }] }] },
79
+ { type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'A line' }, { type: 'hardBreak' }, { type: 'text', text: 'broken here' }] },
80
+ ])
81
+ // The quote's first line ends at 10, its second starts at 12; the paragraph opens at 22, its break at 29.
82
+ e.commands.setTextSelection({ from: 10, to: 12 })
83
+ expect(anchorOfSelection(e)).toBeNull()
84
+ e.commands.setTextSelection({ from: 29, to: 30 })
85
+ expect(anchorOfSelection(e)).toBeNull()
86
+ })
87
+
88
+ it('quotes a list item as its own words, its nested items as the items after it', () => {
89
+ const item = (blockId: string, text: string, nested?: object) => ({
90
+ type: 'listItem',
91
+ attrs: { blockId },
92
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }, ...(nested ? [nested] : [])],
93
+ })
94
+ const e = open([{ type: 'bulletList', attrs: { blockId: 'l1' }, content: [item('a', 'alpha', { type: 'bulletList', content: [item('b', 'beta')] }), item('c', 'gamma')] }])
95
+ // "alpha" at 3–8; the nested list's item "beta" at 12–16.
96
+ e.commands.setTextSelection({ from: 3, to: 8 })
97
+ expect(anchorOfSelection(e)).toEqual({ blocks: ['a'] })
98
+ // The list's text on the wire is "alpha\nbeta\ngamma": the nested item sits between its parent and the next.
99
+ expect(commentPlace(e, { id: 'c', blocks: ['l1'], quote: { start: 'alpha\nbeta' } })).toEqual({ state: 'resolved', from: 3, to: 16, words: true })
100
+ })
101
+
102
+ it('quotes a URL chip as its URL, as on the wire', () => {
103
+ const content = [
104
+ { type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'see ' }, { type: 'reference', attrs: { uri: '', href: 'https://ship.estiva.app/x' } }, { type: 'text', text: ' ok' }] },
105
+ ]
106
+ const e = (editor = new Editor({ extensions: [StarterKit, BlockId, ReferenceNode, CommentHighlights], content: { type: 'doc', content } }))
107
+ expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'see https://ship' } })).toMatchObject({ state: 'resolved', from: 1 })
66
108
  })
67
109
 
68
110
  it('keeps the words around a quote only when the quoted words repeat', () => {
@@ -88,6 +130,18 @@ describe('commentPlace', () => {
88
130
  expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'omega' } })).toEqual({ state: 'reworded', from: 0, to: 18 })
89
131
  })
90
132
 
133
+ it('finds words across two lines of a quote, as the protocol writes them', () => {
134
+ const e = open([
135
+ { type: 'blockquote', attrs: { blockId: 'q1' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one line' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'two line' }] }] },
136
+ ])
137
+ expect(commentPlace(e, { id: 'c', blocks: ['q1'], quote: { start: 'line\ntwo' } })).toEqual({ state: 'resolved', from: 6, to: 15, words: true })
138
+ })
139
+
140
+ it('finds words across a line break, as the protocol writes them', () => {
141
+ const e = open([{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'A line' }, { type: 'hardBreak' }, { type: 'text', text: 'broken here' }] }])
142
+ expect(commentPlace(e, { id: 'c', blocks: ['p1'], quote: { start: 'line\nbroken' } })).toEqual({ state: 'resolved', from: 3, to: 14, words: true })
143
+ })
144
+
91
145
  it('says detached when none of the blocks is left', () => {
92
146
  const e = open()
93
147
  expect(commentPlace(e, { id: 'c', blocks: ['gone'], quote: { start: 'alpha' } })).toEqual({ state: 'detached' })
@@ -106,27 +106,64 @@ function placesIn(doc: ProseMirrorNode): Map<string, Place> {
106
106
 
107
107
  /**
108
108
  * A block's text, and the document position where each of its characters
109
- * starts and ends. A chip counts as its `nostr:` text, as on the wire.
109
+ * starts and ends. It is the text the protocol's `inlineTextOf` reads from the
110
+ * same block on the wire, or a quote written here would not be found there: a
111
+ * chip counts as the text it is on the wire (a URL chip its URL, any other its
112
+ * `nostr:` reference), a line break as `\n`, and the blocks inside a block (a
113
+ * quote's lines, a list's items, a table's cells) are joined with `\n`. A list
114
+ * item is its own text: the list inside it is flat on the wire, its items
115
+ * following it in the list.
110
116
  */
111
117
  function textOf(block: Place): { text: string; starts: number[]; ends: number[] } {
112
118
  let text = ''
113
119
  const starts: number[] = []
114
120
  const ends: number[] = []
115
- block.node.descendants((node, offset) => {
116
- const at = block.pos + 1 + offset
117
- if (node.isText && node.text) {
118
- for (let i = 0; i < node.text.length; i++) (starts.push(at + i), ends.push(at + i + 1))
119
- text += node.text
120
- } else if (node.isInline && node.isLeaf) {
121
- const wire = typeof node.attrs.uri === 'string' ? node.attrs.uri : ''
122
- for (let i = 0; i < wire.length; i++) (starts.push(at), ends.push(at + node.nodeSize))
123
- text += wire
121
+ const add = (chars: string, start: number, end: number) => {
122
+ for (let i = 0; i < chars.length; i++) (starts.push(start), ends.push(end))
123
+ text += chars
124
+ }
125
+ const walk = (node: ProseMirrorNode, pos: number) => {
126
+ if (node.isTextblock) {
127
+ node.forEach((child, offset) => {
128
+ const at = pos + 1 + offset
129
+ if (child.isText && child.text) {
130
+ for (let i = 0; i < child.text.length; i++) (starts.push(at + i), ends.push(at + i + 1))
131
+ text += child.text
132
+ } else if (child.type.name === 'hardBreak') add('\n', at, at + child.nodeSize)
133
+ else if (child.isInline && child.isLeaf) add(String(child.attrs.href || child.attrs.uri || ''), at, at + child.nodeSize)
134
+ })
135
+ return
124
136
  }
125
- return true
126
- })
137
+ // A list item's paragraphs are one run on the wire, with nothing between them.
138
+ if (node.type.name === 'listItem') {
139
+ node.forEach((child, offset) => void (LISTS.has(child.type.name) || walk(child, pos + 1 + offset)))
140
+ return
141
+ }
142
+ const children = LISTS.has(node.type.name) ? itemsOf(node, pos) : childrenOf(node, pos)
143
+ children.forEach(({ node: child, pos: at }, index) => {
144
+ // Between two blocks: the boundary, where neither one's text is.
145
+ if (index > 0) add('\n', at, at)
146
+ walk(child, at)
147
+ })
148
+ }
149
+ walk(block.node, block.pos)
127
150
  return { text, starts, ends }
128
151
  }
129
152
 
153
+ /** The lists whose nested items the wire holds flat, after the item they are in. */
154
+ const LISTS = new Set(['bulletList', 'orderedList'])
155
+
156
+ function childrenOf(node: ProseMirrorNode, pos: number): { node: ProseMirrorNode; pos: number }[] {
157
+ const out: { node: ProseMirrorNode; pos: number }[] = []
158
+ node.forEach((child, offset) => void out.push({ node: child, pos: pos + 1 + offset }))
159
+ return out
160
+ }
161
+
162
+ /** A list's items as the wire lists them: each one, then the items of a list inside it. */
163
+ function itemsOf(list: ProseMirrorNode, pos: number): { node: ProseMirrorNode; pos: number }[] {
164
+ return childrenOf(list, pos).flatMap((item) => [item, ...childrenOf(item.node, item.pos).flatMap((c) => (LISTS.has(c.node.type.name) ? itemsOf(c.node, c.pos) : []))])
165
+ }
166
+
130
167
  /** Where `words` sits in `text` at or after `from`: the occurrence whose context matches, else the first. */
131
168
  function find(text: string, words: string, from: number, context: (at: number) => boolean): number {
132
169
  let first = -1
@@ -391,6 +428,8 @@ export function anchorOfSelection(editor: Editor): SelectionAnchor | null {
391
428
  if (run.length === 1) lastText = firstText
392
429
  // Between two blocks, with no character selected: nothing to comment on.
393
430
  if (run.length === 1 && firstText.text && start >= end) return null
431
+ // Only a line break, or the edge between two lines of one block: no word to comment on either.
432
+ if (run.length === 1 && firstText.text && !firstText.text.slice(start, end).replace(/\n/g, '')) return null
394
433
  const blocks = run.map((p) => p.node.attrs.blockId as string)
395
434
  if (start === 0 && end >= lastText.text.length) return { blocks }
396
435
 
@@ -176,6 +176,27 @@ describe('[', () => {
176
176
  expect(icons).toEqual(['kind-a', 'own', 'list'])
177
177
  })
178
178
 
179
+ it('draws a summary as the second line, with where the thing sits beside the label; without one, the row is as before (SUM-7)', async () => {
180
+ const rows = [
181
+ { id: 's', label: 'Ship launch', snippet: '', uri: 'nostr:s', description: 'SHI-12 · Ship 1', summary: 'What we show on Friday' },
182
+ { id: 'n', label: 'Old plan', snippet: '', uri: 'nostr:n', description: 'SHI-3 · Ship 1' },
183
+ ]
184
+ function Summarised() {
185
+ const made = useEditor({
186
+ extensions: [StarterKit, Chip, ReferenceTrigger.configure({ items: () => rows, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things' })],
187
+ })
188
+ editor = made
189
+ return <EditorContent editor={made} />
190
+ }
191
+ render(<Summarised />)
192
+ await type('[')
193
+ const [withSummary, without] = within(screen.getByRole('listbox')).getAllByRole('option')
194
+ // The line above a row's second line, as a person reads it.
195
+ const lineAbove = (row: HTMLElement, second: string) => within(row).getByText(second).previousElementSibling?.textContent
196
+ expect(lineAbove(withSummary, 'What we show on Friday')).toBe('Ship launchSHI-12 · Ship 1')
197
+ expect(lineAbove(without, 'SHI-3 · Ship 1')).toBe('Old plan')
198
+ })
199
+
179
200
  it('draws the app’s sections in order, skips an empty one, and redraws the open list when told', async () => {
180
201
  const listeners = new Set<() => void>()
181
202
  let late: CaptionedItem[] = []
@@ -232,6 +232,13 @@ export interface CaptionedItem {
232
232
  search?: string
233
233
  /** The row's second line, e.g. where the thing sits. The chip does not wear it. */
234
234
  description?: string
235
+ /**
236
+ * What the thing is about, in a line of plain text — a file's summary. When
237
+ * set, it is the row's second line and `description` moves up after the
238
+ * label, muted; absent, the row is as before. The chip does not wear it, and
239
+ * the query is not matched against it.
240
+ */
241
+ summary?: string
235
242
  /**
236
243
  * This row's own 16px icon, in place of the list's `icon`: what the thing is,
237
244
  * when the list holds several kinds — a topic, a project, a message
@@ -363,7 +370,13 @@ export function referenceMenu({ ariaLabel, sectionLabel, icon, iconFor }: Refere
363
370
  return groups
364
371
  },
365
372
  itemKey: (item) => `reference-${item.id}`,
366
- row: (item) => ({ leading: item.icon ?? iconFor?.(item.uri) ?? icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
373
+ row: (item) => ({
374
+ leading: item.icon ?? iconFor?.(item.uri) ?? icon,
375
+ label: captionOf(item.label, item.snippet),
376
+ // A summary is the second line, and where the thing sits moves up beside the label (SUM-7's story).
377
+ ...(item.summary ? { description: item.summary, aside: item.description } : { description: item.description }),
378
+ hint: <EnterHint />,
379
+ }),
367
380
  }
368
381
  }
369
382
 
package/src/Menu.tsx CHANGED
@@ -350,6 +350,12 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
350
350
  size?: 'default' | 'tall'
351
351
  /** A second line under the label — a role, an address — 12px, secondary, truncating. */
352
352
  description?: string
353
+ /**
354
+ * After the label, on its line — 12px, muted, never cut: where a file sits
355
+ * (an issue's ref and project) when its second line is its summary (SUM-7).
356
+ * The label truncates before it does.
357
+ */
358
+ aside?: string
353
359
  /** Before the label: a 16px icon (stroke 1.5, secondary), or an Avatar, for rows led by a face. */
354
360
  leading?: ReactNode
355
361
  /** At the right edge: a hint, a value — anything. Wins over `shortcut` and `submenu`. */
@@ -425,7 +431,7 @@ export function menuItemClassName({ size, selected, className }: { size: 'defaul
425
431
  /** Everything inside the row — written once, for the same reason. Shared
426
432
  * inside the package like `menuItemClassName`: `CommandPalette` draws it
427
433
  * inside Base UI's `Autocomplete.Item`. Not exported from the index. */
428
- export function MenuItemBody({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected }: Pick<MenuItemProps, 'label' | 'children' | 'size' | 'description' | 'leading' | 'trailing' | 'hint' | 'shortcut' | 'submenu' | 'destructive' | 'selected'>) {
434
+ export function MenuItemBody({ label, children, size = 'default', description, aside, leading, trailing, hint, shortcut, submenu, destructive, selected }: Pick<MenuItemProps, 'label' | 'children' | 'size' | 'description' | 'aside' | 'leading' | 'trailing' | 'hint' | 'shortcut' | 'submenu' | 'destructive' | 'selected'>) {
429
435
  const edge =
430
436
  trailing ??
431
437
  (shortcut ? (
@@ -441,9 +447,16 @@ export function MenuItemBody({ label, children, size = 'default', description, l
441
447
  colour. Ship's copy said `text-sm`, which was never the ramp. */}
442
448
  {children ?? (
443
449
  <span className={cn('flex min-w-0 flex-1 flex-col', size === 'tall' && 'gap-[2px]')}>
444
- <span className={cn('truncate text-body-2', destructive ? 'text-error-default' : 'text-text-primary')}>
445
- {label}
446
- </span>
450
+ {aside ? (
451
+ <span className="flex min-w-0 items-baseline gap-2">
452
+ <span className={cn('truncate text-body-2', destructive ? 'text-error-default' : 'text-text-primary')}>{label}</span>
453
+ <span className="shrink-0 text-caption text-text-muted">{aside}</span>
454
+ </span>
455
+ ) : (
456
+ <span className={cn('truncate text-body-2', destructive ? 'text-error-default' : 'text-text-primary')}>
457
+ {label}
458
+ </span>
459
+ )}
447
460
  {description && <span className="truncate text-caption text-text-secondary">{description}</span>}
448
461
  </span>
449
462
  )}
@@ -469,12 +482,13 @@ export function MenuItemBody({ label, children, size = 'default', description, l
469
482
  )
470
483
  }
471
484
 
472
- export function MenuItem({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
485
+ export function MenuItem({ label, children, size = 'default', description, aside, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
473
486
  const body = (
474
487
  <MenuItemBody
475
488
  label={label}
476
489
  size={size}
477
490
  description={description}
491
+ aside={aside}
478
492
  leading={leading}
479
493
  trailing={trailing}
480
494
  hint={hint}
@@ -34,6 +34,8 @@ import { Popover } from './Popover'
34
34
  export interface SuggestionMenuRow {
35
35
  label: string
36
36
  description?: string
37
+ /** After the label, muted — `MenuItem`'s. */
38
+ aside?: string
37
39
  leading?: ReactNode
38
40
  /** What you would type — drawn as keys at the right, e.g. `#` for a heading. */
39
41
  shortcut?: string
@@ -158,7 +160,7 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
158
160
  const drawRow = (item: T) => {
159
161
  const index = items.indexOf(item)
160
162
  const selected = index === highlight
161
- const { label, description, leading, shortcut, hint, size } = row(item)
163
+ const { label, description, aside, leading, shortcut, hint, size } = row(item)
162
164
  return (
163
165
  // A row of a list the editor drives from its caret: focus stays in the text, so the row is no Tab stop.
164
166
  <MenuItem
@@ -171,6 +173,7 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
171
173
  size={size}
172
174
  label={label}
173
175
  description={description}
176
+ aside={aside}
174
177
  leading={leading}
175
178
  shortcut={shortcut}
176
179
  hint={hint}