@estiva-app/ui 0.73.0-inline-text.1 → 0.74.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.73.0-inline-text.1",
3
+ "version": "0.74.0",
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-inline-text.1",
7
+ "packageVersion": "0.74.0",
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 />}
@@ -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
 
@@ -168,6 +168,27 @@ describe('InlineText: a refused save, and a change from elsewhere', () => {
168
168
  expect(field().textContent).toBe('Item one two')
169
169
  })
170
170
 
171
+ it('Enter and the leave that follows it save once while the save is still in flight', async () => {
172
+ let answer: (saved: boolean) => void = () => {}
173
+ const onCommit = vi.fn(() => new Promise<boolean>((resolve) => (answer = resolve)))
174
+ draw({ onCommit, onEnter: () => field().blur() })
175
+ await enter()
176
+ await type(' two')
177
+ await userEvent.keyboard('{Enter}')
178
+ await act(async () => answer(true))
179
+ expect(onCommit).toHaveBeenCalledTimes(1)
180
+ })
181
+
182
+ it('a change from elsewhere made while you are in it, and you change nothing, is loaded when you leave', async () => {
183
+ const { rerender, onCommit } = draw()
184
+ await enter()
185
+ rerender({ value: 'Item three' })
186
+ expect(field().textContent).toBe('Item one')
187
+ await act(async () => field().blur())
188
+ expect(field().textContent).toBe('Item three')
189
+ expect(onCommit).not.toHaveBeenCalled()
190
+ })
191
+
171
192
  it('a change from elsewhere is loaded while you are not in it', async () => {
172
193
  const { rerender } = draw()
173
194
  rerender({ value: 'Item three' })
@@ -98,14 +98,22 @@ export function InlineText({ value, placeholder, label, onCommit, onText, onEnte
98
98
  const [empty, setEmpty] = useState(!value)
99
99
  // Read at key time: the editor's handlers are built once, and a prop they
100
100
  // closed over would be the first render's.
101
- const latest = useRef({ onEnter, onText, onCommit, clearable })
102
- latest.current = { onEnter, onText, onCommit, clearable }
101
+ const latest = useRef({ onEnter, onText, onCommit, clearable, value })
102
+ latest.current = { onEnter, onText, onCommit, clearable, value }
103
+ // The text being saved right now: Enter moves on, and the leave that follows
104
+ // must not send the same save a second time while the first is in flight.
105
+ const pending = useRef<string | null>(null)
103
106
 
104
107
  const save = useCallback(async (text: string) => {
105
108
  const next = oneLine(text)
106
- if (next === saved.current) return
109
+ if (next === saved.current || next === pending.current) return
107
110
  if (!next && !latest.current.clearable) return
108
- if (await latest.current.onCommit(next)) saved.current = next
111
+ pending.current = next
112
+ try {
113
+ if (await latest.current.onCommit(next)) saved.current = next
114
+ } finally {
115
+ if (pending.current === next) pending.current = null
116
+ }
109
117
  }, [])
110
118
 
111
119
  const editor = useEditor({
@@ -156,6 +164,15 @@ export function InlineText({ value, placeholder, label, onCommit, onText, onEnte
156
164
  setEmpty(false)
157
165
  return
158
166
  }
167
+ // Left unchanged while someone else changed it: their text comes in now,
168
+ // as it would have had you not been in it.
169
+ const { value: current } = latest.current
170
+ if (oneLine(e.getText()) === saved.current && current !== saved.current && pending.current === null) {
171
+ e.commands.setContent(doc(current), { emitUpdate: false })
172
+ saved.current = current
173
+ setEmpty(!current)
174
+ return
175
+ }
159
176
  void save(e.getText())
160
177
  },
161
178
  }, [readOnly])
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}